Checklist Bulgaria selection 2025 IOAI Team Selection · Task 6
Dual-Task Tile Recognition Challenge
For each tile of a shuffled mix of two STL-10 images, predict which image it came from and its original 3x3 position.
The task
Two STL-10 images of different classes are resized to 96x96, each split into a 3x3 grid of 32x32 tiles, and the 18 tiles are shuffled into a 3x6 grid. For every tile the model must predict the source image (0 = first, brighter image; 1 = second, darker image) and the tile's original position (0-8).
The model class must be named TileClassifier, have at least 3 Conv2d layers with batch normalisation and pooling and at least 2 Linear layers per head, accept tensors [Bx18, 3, 32, 32] and output source logits [Bx18, 2] and position logits [Bx18, 9]; loss, optimiser and learning rate are free.
Abridged by SOTA from the official materials. The official statement has the exact rules, and it wins wherever this summary differs.
In English
Some of this task's files were published only in Bulgarian. SOTA translated that file into English on 17 September 2026. Only the words changed in the notebooks: markdown, code comments, messages and printed output. The code, file names and paths are the original's, so a translated notebook runs with the original data.
- Contestant's solution by Bozhidara Puhaleva (with the statement) Bulgarian original of Contestant's solution by Bozhidara Puhaleva (with the statement)
Read the contestant's solution by bozhidara puhaleva (with the statement) in English
Dual Tile Recognition Task
Introduction: This task involves the analysis of mixed images, created from pairs of images from the STL-10 dataset, with the aim of predicting both the source image and the original position of each individual tile.
I. Overview of the problem
The task uses images created by combining two different images from STL-10 with different class labels. Each original image is resized to 96x96 pixels and split into a 3x3 grid of tiles (each of size 32x32 pixels). All 18 tiles from the two images are then shuffled at random and arranged into a 3x6 grid, creating mixed images for analysis.
II. Dataset
The dataset consists of 5000 mixed images for training and 500 mixed images for validation. Each mixed image contains 18 tiles from two different source images from STL-10. The data are split, with the images and labels prepared for immediate use. The file sizes are optimised for fast training on moderately powerful hardware or on free graphics processing units (GPUs) in Colab.
III. Task
Design and implement a neural network model in PyTorch that analyses the mixed images and predicts two properties for every tile. The specific requirements are as follows:
- Source classification: Determine which original image the tile comes from:
- Label 0: Tile from the first source image (the brighter image).
- Label 1: Tile from the second source image (the darker image).
- Position classification: Identify the original position of the tile in the 3x3 grid:
- Labels 0–8: Corresponding to the positions in the original 3x3 grid.
Model requirements:
- Name the model class
TileClassifier(). Using other names may lead to problems during scoring. - Include at least 3 convolutional layers (nn.Conv2d) with the corresponding batch normalization and pooling layers.
- Include at least 2 fully connected layers (nn.Linear) for each of the two classification heads.
-
- Use suitable activation functions, for example nn.ReLU.
- The model must accept input tensors with dimensions [B×18, 3, 32, 32], where B is the batch size.
- The model must return two sets of logits: source_logits with dimensions [B×18, 2] and position_logits with dimensions [B×18, 9].
- The loss function, the optimiser and the learning rate may be chosen freely.
IV. Scoring
Performance is measured with accuracy metrics for the two classification tasks. Scoring is carried out on a test set of 5000 shuffled images:
- Source Accuracy::
correct_source_predictions / total_tiles - Position Accuracy:
correct_position_predictions / total_tiles
V. Submission requirements
For the final submission, archive the following 2 files:
- A file with the predictions in JSON format (
predictions.json). The file must contain the predictions for all tiles of the test set, in sequential order. File format:
[
{"source": 0, "position": 5},
{"source": 1, "position": 2},
...
]
- This notebook, renamed appropriately.
!pip install gdown
!gdown 1_cm2IzYrohgqnXEWBEXO1UtYBW2UqFdZ
! unzip mixed_dataset_competition.zip
########## BASELINE
# ====================================================================================================
# CONTESTANTS' ZONE: Change the code below this line, keeping the interfaces of the methods and classes
# ====================================================================================================
# Some ideas:
# 1. Add global-context features that take into account the relations between all tiles, instead of processing each one independently.
# 2. Experiment with different pooling strategies and methods for aggregating features in the network.
# 3. Try multi-scale feature extraction approaches in order to capture information at different levels of detail.
# 4. Change the way in which features are shared or split between the two classification tasks within the model architecture.
# 5. Adjust the depth, width and connection schemes of the network in order to better capture the hierarchical nature of visual features.
class TileClassifier(nn.Module):
def __init__(self, kernel_size=3, num_layers=3):
super(TileClassifier, self).__init__()
# Defining the basic CNN layers
cnn_layers = []
in_channels = 3
# Configuring the channels for each layer
channels = [32, 64, 128, 256][:num_layers]
for i, out_channels in enumerate(channels):
cnn_layers.append(
nn.Conv2d(
in_channels,
out_channels,
kernel_size=kernel_size,
padding=kernel_size // 2,
)
)
cnn_layers.append(nn.BatchNorm2d(out_channels))
cnn_layers.append(nn.ReLU(inplace=True))
cnn_layers.append(nn.MaxPool2d(2))
in_channels = out_channels
self.cnn = nn.Sequential(*cnn_layers)
# Compute the size of the output features from the input and the pooling layers
feature_size = 32 // (2**num_layers)
if feature_size < 1:
feature_size = 1 # Minimum feature size
flat_features = channels[-1] * feature_size * feature_size
# Position classifier (9 positions) - not changed
self.position_classifier = nn.Sequential(
nn.Linear(flat_features, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 9)
)
self.source_classifier = nn.Sequential(
nn.Linear(flat_features, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 2)
)
def forward(self, x):
# x shape: [B * 18, 3, 32, 32], where B is batch_size from DataLoader
num_tiles_per_image = 18 # Fixed for this problem
tile_cnn_features = self.cnn(x)
flat_tile_features = torch.flatten(tile_cnn_features, 1)
position_logits = self.position_classifier(flat_tile_features)
source_logits = self.source_classifier(flat_tile_features)
return source_logits, position_logits
def setup_training_components(device):
lr = 0.001
kernel_size = 3
num_layers = 2
model = TileClassifier(kernel_size=kernel_size, num_layers=num_layers).to(device)
print("Model architecture:")
print(model)
criterion_source = nn.CrossEntropyLoss()
criterion_position = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=lr)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=5)
return model, criterion_source, criterion_position, optimizer, scheduler
# ==================================================================================
# DO NOT CHANGE THE CODE BELOW THIS LINE!
# ==================================================================================
########## BASELINE
# ====================================================================================================
# CONTESTANTS' ZONE: Change the code below this line, keeping the interfaces of the methods and classes
# ====================================================================================================
# Some ideas:
# 1. Add global-context features that take into account the relations between all tiles, instead of processing each one independently.
# 2. Experiment with different pooling strategies and methods for aggregating features in the network.
# 3. Try multi-scale feature extraction approaches in order to capture information at different levels of detail.
# 4. Change the way in which features are shared or split between the two classification tasks within the model architecture.
# 5. Adjust the depth, width and connection schemes of the network in order to better capture the hierarchical nature of visual features.
########## BEST
import torch.nn.functional as F
import torch.nn as nn
import torch
class TileClassifier(nn.Module):
def __init__(self):
super(TileClassifier, self).__init__()
self.cnn = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 32->16
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), # 16->8
nn.Conv2d(128, 256, kernel_size=3, padding=1),
nn.BatchNorm2d(256), nn.ReLU(), nn.MaxPool2d(2), # 8->4
nn.Conv2d(256, 512, kernel_size=3, padding=1),
nn.BatchNorm2d(512), nn.ReLU(), nn.AdaptiveAvgPool2d(1), # 4->1x1, feature vector per tile
)
self.feature_dim = 512 # 512*1*1 = 512
# Attention layer for global context
self.attention = nn.MultiheadAttention(embed_dim=self.feature_dim, num_heads=8)
# Classification heads
self.position_classifier = nn.Sequential(
nn.Linear(self.feature_dim * 2, 256), nn.ReLU(),
nn.Dropout(0.3), nn.Linear(256, 9)
)
self.source_classifier = nn.Sequential(
nn.Linear(self.feature_dim * 2, 256), nn.ReLU(),
nn.Dropout(0.3), nn.Linear(256, 2)
)
def forward(self, x):
# x: (B*18, 3, 32, 32)
B_tiles = x.size(0)
features = self.cnn(x).view(B_tiles, -1) # (B*18, feature_dim)
B = B_tiles // 18
features = features.view(B, 18, self.feature_dim) # (B, 18, D)
# Prepare for attention: (seq_len, batch, embed_dim)
features_t = features.permute(1, 0, 2) # (18, B, D)
# Self-attention over tiles to get context-aware features
attn_output, _ = self.attention(features_t, features_t, features_t) # (18, B, D)
attn_output = attn_output.permute(1, 0, 2) # (B, 18, D)
# Combine local + global context from attention
global_context = attn_output.mean(dim=1, keepdim=True).repeat(1, 18, 1) # (B,18,D)
features_with_context = torch.cat([features, global_context], dim=2) # (B,18,2D)
# Flatten back
features_with_context = features_with_context.view(B*18, -1) # (B*18, 2D)
position_logits = self.position_classifier(features_with_context)
source_logits = self.source_classifier(features_with_context)
return source_logits, position_logits
def setup_training_components(device):
lr = 0.001
kernel_size = 3
num_layers = 2
#model = TileClassifier(kernel_size=kernel_size, num_layers=num_layers).to(device)
model = TileClassifier().to(device)
print("Model architecture:")
print(model)
criterion_source = nn.CrossEntropyLoss()
criterion_position = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=5)
return model, criterion_source, criterion_position, optimizer, scheduler
# ==================================================================================
# DO NOT CHANGE THE CODE BELOW THIS LINE!
# ==================================================================================
import torch.nn.functional as F
import torch.nn as nn
import torch
import torch.optim as optim
class TileClassifier(nn.Module):
def __init__(self):
super(TileClassifier, self).__init__()
self.cnn = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 32->16
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), # 16->8
nn.Conv2d(128, 256, kernel_size=3, padding=1),
nn.BatchNorm2d(256), nn.ReLU(), nn.MaxPool2d(2), # 8->4
nn.Conv2d(256, 512, kernel_size=3, padding=1),
nn.BatchNorm2d(512), nn.ReLU(), nn.AdaptiveAvgPool2d(1), # 4->1x1, feature vector per tile
)
self.feature_dim = 512 # 512*1*1 = 512
# Attention layer for global context
self.attention = nn.MultiheadAttention(embed_dim=self.feature_dim, num_heads=8)
# LayerNorm layers for Transformer-style residual connections
self.ln1 = nn.LayerNorm(self.feature_dim)
self.ln2 = nn.LayerNorm(self.feature_dim)
# Feed-forward block (like Transformer FFN)
self.ff = nn.Sequential(
nn.Linear(self.feature_dim, self.feature_dim),
nn.ReLU(),
nn.Linear(self.feature_dim, self.feature_dim)
)
# Classification heads
self.position_classifier = nn.Sequential(
nn.Linear(self.feature_dim * 2, 256), nn.ReLU(),
nn.Dropout(0.3), nn.Linear(256, 9)
)
self.source_classifier = nn.Sequential(
nn.Linear(self.feature_dim * 2, 256), nn.ReLU(),
nn.Dropout(0.3), nn.Linear(256, 2)
)
def forward(self, x):
# x: (B*18, 3, 32, 32)
B_tiles = x.size(0)
features = self.cnn(x).view(B_tiles, -1) # (B*18, feature_dim)
B = B_tiles // 18
features = features.view(B, 18, self.feature_dim) # (B, 18, D)
# Prepare for attention: (seq_len, batch, embed_dim)
features_t = features.permute(1, 0, 2) # (18, B, D)
# Multihead Attention with residual and LayerNorm
attn_output, _ = self.attention(features_t, features_t, features_t) # (18, B, D)
x = self.ln1(features_t + attn_output) # Residual + LayerNorm
ff_out = self.ff(x) # Feed-forward network
x = self.ln2(x + ff_out) # Residual + LayerNorm
attn_output = x.permute(1, 0, 2) # (B, 18, D)
# Combine local + global context from attention
global_context = attn_output.mean(dim=1, keepdim=True).repeat(1, 18, 1) # (B,18,D)
features_with_context = torch.cat([features, global_context], dim=2) # (B,18,2D)
# Flatten back
features_with_context = features_with_context.view(B*18, -1) # (B*18, 2D)
position_logits = self.position_classifier(features_with_context)
source_logits = self.source_classifier(features_with_context)
return source_logits, position_logits
def setup_training_components(device):
lr = 0.001
#model = TileClassifier(kernel_size=kernel_size, num_layers=num_layers).to(device)
model = TileClassifier().to(device)
print("Model architecture:")
print(model)
criterion_source = nn.CrossEntropyLoss()
criterion_position = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=5)
return model, criterion_source, criterion_position, optimizer, scheduler
def get_device():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
return device
def get_data_loaders(args):
train_dataset = MixedSTL10Dataset(root_dir=args.data_dir, split="train")
val_dataset = MixedSTL10Dataset(root_dir=args.data_dir, split="val")
print(f"Training set size: {len(train_dataset)}")
print(f"Validation set size: {len(val_dataset)}")
train_loader = DataLoader(train_dataset, batch_size=args.batch_size, shuffle=True, num_workers=4)
val_loader = DataLoader(val_dataset, batch_size=args.batch_size, shuffle=False, num_workers=4)
return train_loader, val_loader
def train_one_epoch(model, loader, crit_source, crit_pos, optimizer, device, sw, pw):
model.train()
total_loss, src_correct, pos_correct, total = 0.0, 0, 0, 0
for tiles, src_labels, pos_labels in loader:
batch_size = tiles.size(0)
tiles = tiles.view(-1, 3, 32, 32).to(device)
src_labels = src_labels.view(-1).to(device)
pos_labels = pos_labels.view(-1).to(device)
src_logits, pos_logits = model(tiles)
src_loss = crit_source(src_logits, src_labels)
pos_loss = crit_pos(pos_logits, pos_labels)
loss = sw * src_loss + pw * pos_loss
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item() * batch_size
src_correct += (src_logits.argmax(1) == src_labels).sum().item()
pos_correct += (pos_logits.argmax(1) == pos_labels).sum().item()
total += src_labels.size(0)
return {
"loss": total_loss / len(loader.dataset),
"source_acc": src_correct / total,
"position_acc": pos_correct / total,
}
def evaluate(model, loader, crit_source, crit_pos, device, sw, pw):
model.eval()
total_loss, src_correct, pos_correct, total = 0.0, 0, 0, 0
with torch.no_grad():
for tiles, src_labels, pos_labels in loader:
batch_size = tiles.size(0)
tiles = tiles.view(-1, 3, 32, 32).to(device)
src_labels = src_labels.view(-1).to(device)
pos_labels = pos_labels.view(-1).to(device)
src_logits, pos_logits = model(tiles)
src_loss = crit_source(src_logits, src_labels)
pos_loss = crit_pos(pos_logits, pos_labels)
loss = sw * src_loss + pw * pos_loss
total_loss += loss.item() * batch_size
src_correct += (src_logits.argmax(1) == src_labels).sum().item()
pos_correct += (pos_logits.argmax(1) == pos_labels).sum().item()
total += src_labels.size(0)
return {
"loss": total_loss / len(loader.dataset),
"source_acc": src_correct / total,
"position_acc": pos_correct / total,
}
def print_epoch_summary(epoch, total_epochs, start_time, train_metrics, val_metrics):
print(f"Epoch {epoch + 1}/{total_epochs} | Time: {time.time() - start_time:.2f}s")
print(
f"Train Loss: {train_metrics['loss']:.4f} | Source Acc: {train_metrics['source_acc']:.4f} | "
f"Position Acc: {train_metrics['position_acc']:.4f}"
)
print(
f"Val Loss: {val_metrics['loss']:.4f} | Source Acc: {val_metrics['source_acc']:.4f} | "
f"Position Acc: {val_metrics['position_acc']:.4f}"
)
def save_model_checkpoint(model, optimizer, epoch, metrics, path):
torch.save({
"epoch": epoch,
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"val_loss": metrics["loss"],
"val_source_acc": metrics["source_acc"],
"val_position_acc": metrics["position_acc"],
}, path)
print(f"Saved new best model with validation loss: {metrics['loss']:.4f}")
def train_model(args):
device = get_device()
train_loader, val_loader = get_data_loaders(args)
model, criterion_source, criterion_position, optimizer, scheduler = setup_training_components(device)
best_val_loss = float("inf")
os.makedirs(args.output_dir, exist_ok=True)
best_model_path = os.path.join(args.output_dir, "best_model.pth")
source_weight = 2.0
position_weight = 1.0
print("Starting training...")
for epoch in range(args.epochs):
start_time = time.time()
train_metrics = train_one_epoch(
model, train_loader, criterion_source, criterion_position, optimizer, device, source_weight, position_weight
)
val_metrics = evaluate(
model, val_loader, criterion_source, criterion_position, device, source_weight, position_weight
)
scheduler.step(val_metrics['loss'])
print_epoch_summary(epoch, args.epochs, start_time, train_metrics, val_metrics)
if val_metrics["loss"] < best_val_loss:
best_val_loss = val_metrics["loss"]
save_model_checkpoint(model, optimizer, epoch, val_metrics, best_model_path)
print(f"Training complete. Best validation loss: {best_val_loss:.4f}")
return model
def create_submission_file(model, test_loader, device, output_path="predictions.json"):
model.eval()
all_predictions = []
print("Generating predictions for the test set...")
with torch.no_grad():
for tiles, _ in test_loader:
tiles = tiles.view(-1, 3, 32, 32).to(device)
src_logits, pos_logits = model(tiles)
src_preds = torch.argmax(src_logits, dim=1).cpu().numpy()
pos_preds = torch.argmax(pos_logits, dim=1).cpu().numpy()
for i in range(src_preds.shape[0]):
all_predictions.append({
"source": int(src_preds[i]),
"position": int(pos_preds[i])
})
with open(output_path, 'w') as f:
json.dump(all_predictions, f, indent=4)
print(f"Predictions saved to {output_path}")
def visualize_mixed_and_reordered(dataset, idx):
tiles, source_labels, position_labels = dataset[idx] # tiles shape: [18, 3, 32, 32]
tiles_np = tiles.permute(0, 2, 3, 1).numpy() # Shape: [18, 32, 32, 3]
scrambled_image = np.zeros((3 * 32, 6 * 32, 3), dtype=np.float32)
for i in range(18):
row = i // 6
col = i % 6
scrambled_image[row*32:(row+1)*32, col*32:(col+1)*32, :] = tiles_np[i]
source_0_tiles = []
source_1_tiles = []
source_0_positions = []
source_1_positions = []
for i in range(18):
if source_labels[i] == 0:
source_0_tiles.append(tiles_np[i])
source_0_positions.append(position_labels[i])
else:
source_1_tiles.append(tiles_np[i])
source_1_positions.append(position_labels[i])
source_0_sorted_indices = np.argsort(source_0_positions)
source_1_sorted_indices = np.argsort(source_1_positions)
source_0_sorted_tiles = [source_0_tiles[i] for i in source_0_sorted_indices]
source_1_sorted_tiles = [source_1_tiles[i] for i in source_1_sorted_indices]
reordered_image_source0 = np.zeros((3 * 32, 3 * 32, 3), dtype=np.float32)
reordered_image_source1 = np.zeros((3 * 32, 3 * 32, 3), dtype=np.float32)
for i in range(9):
row = i // 3
col = i % 3
reordered_image_source0[row*32:(row+1)*32, col*32:(col+1)*32, :] = source_0_sorted_tiles[i]
reordered_image_source1[row*32:(row+1)*32, col*32:(col+1)*32, :] = source_1_sorted_tiles[i]
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
axes[0].imshow(scrambled_image)
axes[0].set_title("Mixed image")
axes[0].axis("off")
axes[1].imshow(reordered_image_source0)
axes[1].set_title("Reordered image (Source 0)")
axes[1].axis("off")
axes[2].imshow(reordered_image_source1)
axes[2].set_title("Reordered image (Source 1)")
axes[2].axis("off")
plt.tight_layout()
plt.show()
ls
data_dir = "./mixed_dataset"
output_dir = "./model_output"
batch_size = 8
epochs = 10
lr = 0.001
# Creating an args object with these parameters
class Args:
def __init__(self):
self.data_dir = data_dir
self.output_dir = output_dir
self.batch_size = batch_size
self.epochs = epochs
self.lr = lr
args = Args()
train_dataset = MixedSTL10Dataset(root_dir=args.data_dir, split="train")
visualize_mixed_and_reordered(train_dataset, 3)
#BEST
train_model(args)
# Test set and loading
test_dataset = MixedSTL10Dataset(root_dir=args.data_dir, split="test") # Use val as test data for this example
test_loader = DataLoader(test_dataset, batch_size=args.batch_size, shuffle=False, num_workers=4)
device = get_device()
# Load the best model
best_model_path = os.path.join(args.output_dir, "best_model.pth")
checkpoint = torch.load(best_model_path)
model, _, _, _, _ = setup_training_components(device) # Re-initialize model architecture
model.load_state_dict(checkpoint['model_state_dict'])
# Create a file with the final predictions on the test set
create_submission_file(model, test_loader, device)
Translated by SOTA. The Bulgarian original is the official version and wins wherever the two differ. The official task notebook (IOAI_Team_Selection_Bulgaria_Task_6.ipynb) is already in English; this is a contestant's solution whose Bulgarian copy of the statement gives other dataset sizes and needs the imports and MixedSTL10Dataset class from the task notebook. The titles inside the saved figure are still in Bulgarian. If you organise this olympiad and would like the translation removed, email [email protected] and we will take it down.
At a glance
- You get
mixed_dataset/ with train, val and test images and .npy labels for train and val.- You submit
- Model predictions for the tiles (a sample solution writes
predictions.json). - Scoring
- Source accuracy and position accuracy (correct / total tiles); combined score = (source accuracy + position accuracy) / 2.
- Rules
- Model class TileClassifier with the architectural minimums above.
- Format
- Bulgarian IOAI 2025 team selection, Day 2, Task 6. Dates and format are not published in the repository.