Discord

Checklist BdAIO 2024 BdAIO 2024 problems (round not stated) · Task 1

SOTA-AI Editorial

The Clothing Sale

Classify 28×28 grayscale clothing images into 10 categories.

Author
Rishikesh Muralimohan
Written
9 October 2026
Olympiad
Bangladesh Artificial Intelligence Olympiad (BdAIO), organised by the Bangladesh Open Source Network (BDOSN), 2024 · BdAIO 2024 problems (round not stated)

The editorial

Problem domain: Deep learning, computer vision, multiclass image classification
Metric: Classification accuracy

Abridged statement

You are a scientist who is trying to help an online clothing company automate inventory management. You need to build a computer vision model that classifies each clothing item into its type (T-shirt, coat, trousers, etc.).

This problem is directly based on the Fashion-MNIST dataset. If you haven’t checked it out before, I would highly recommend the CNN tutorial.

Dataset analysis

The dataset has 10,000 labelled training entries and 2,000 unlabelled test entries, with each of the 784 pixel columns corresponding to a certain pixel in a 28×28 grayscale image. Because the data is in tabular format, it may be tempting to treat this as a tabular ML problem. However, using CNNs allows us to exploit the spatial relationships between pixels. The classes are approximately balanced, and there are no missing values.

Training class counts:

Class Clothing type Count
0 T-shirt/top 1,003
1 Trouser 972
2 Pullover 969
3 Dress 1,037
4 Coat 1,014
5 Sandal 961
6 Shirt 1,044
7 Sneaker 1,003
8 Bag 943
9 Ankle boot 1,054

EDA summary

  • I randomly sampled some images from the dataset to make sure that I was extracting them from the rows of the CSV file correctly.
  • Plotting the average image of each class seemed to show a clear pattern of the clothing.

Solution strategy

The images and labels resemble the Fashion-MNIST dataset, which motivates us to use a similar solution to that. I like to approach problems in stages starting from a simple baseline solution before improving rather than just going for the most ambitious solution in the beginning.

Each approach below produces a separate submission. The results are classification accuracies from the Kaggle leaderboard.

The earlier models already use data augmentation: horizontal flips, rotations, translations, scaling and random erasing. The later augmentation experiment switches to a lighter policy and removes random erasing. You can ask Gemma for help implementing these augmentations or refer to the torchvision documentation.

Approach Description Private / Public Accuracy
1. Simple CNN (Baseline Solution) Preprocess the data into 28×28 images, normalize pixel values, and use a simple CNN with two convolutional layers and max pooling. 85.32% / 85.96%
2. More Layers and Regularization Use a larger and more complicated CNN with additional convolutional layers, batch normalization and dropout to learn more complex features while reducing overfitting. I would recommend using the contest LLM to help implement this. 89.69% / 89.22%
3. Optimizing Training Use AdamW with weight decay and cosine learning-rate scheduling on the larger CNN. 91.01% / 91.73%
4. Data Augmentation Switch to lighter data augmentation, such as smaller rotations and translations, to make the larger CNN more robust to variations in images. 91.07% / 92.48%
5. Model Ensembling Train the stronger CNN configuration multiple times using different random seeds and average the predicted class probabilities of three models. 91.94% / 92.48%
6. ResNet Try a different architecture that uses residual connections to make deeper networks easier to train, and compare it against the optimized CNN. 91.44% / 91.48%

Implementation

  • I normalised the pixel values and reshaped them into (N, 1, 28, 28).
  • I used cross-entropy loss for the 10-class classification problem.
  • I split the data into training and validation.

Compute

  • This problem involves relatively small 28×28 grayscale images, making it possible to train CNNs without extremely large computational resources.
  • Training on a CPU is possible, although I used a free GPU on Colab to speed up experiments.

Notes

  • The simple CNN gave reasonable results, but the larger CNN was substantially better.
  • Optimizing the training process provided further improvements without changing the architecture.
  • Light data augmentation improved the public leaderboard score, although the private-score improvement over the AdamW configuration was small.
  • Ensembling produced the highest private accuracy of 91.94% among the approaches using only competition training data, suggesting that averaging independently trained models helped improve generalization.
  • ResNet performed competitively, but did not outperform the CNN ensemble on this dataset.

Other possible solutions

  • Pretrained CNNs: Fine-tune a pretrained CNN such as ResNet18 on the competition data.
  • Test-Time Augmentation (TTA): Average predictions across several slightly transformed versions of the same test image. I tried this separately, but it didn't outperform the optimized larger CNN.
  • External Fashion-MNIST Data: I'm not sure whether the competition rules allowed external data, but training on the entire Fashion-MNIST dataset gave around 99.3% private and 99.5% public accuracy. However, many of the competition test images were already present in this dataset, so this result involves data leakage and isn't a fair comparison with the other approaches.

Code

Solution notebook Solution.ipynb · 12 code cells
from pathlib import Path
import random
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import torch
from torch import nn
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
from sklearn.model_selection import train_test_split
from sklearn.metrics import confusion_matrix, classification_report, ConfusionMatrixDisplay
import kagglehub

folder = Path(kagglehub.competition_download("bdaio-2024-the-clothing-sale"))
train_df = pd.read_csv(folder / "mnist_train.csv")
test_df = pd.read_csv(folder / "mnist_test.csv")

pixel_cols = [f"pixel{i}" for i in range(1, 785)]
class_names = ["T-shirt/top", "Trouser", "Pullover", "Dress", "Coat",
               "Sandal", "Shirt", "Sneaker", "Bag", "Ankle boot"]

df = train_df
PIXEL_COLS = pixel_cols
CLASS_NAMES = class_names

output_dir = Path("submissions")
output_dir.mkdir(exist_ok=True)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("Device:", device)
print("Training images:", len(train_df), "Test images:", len(test_df))

Exploratory data analysis (EDA)

28×28 grayscale images, 10 clothing categories. Use only the competition CSVs for this EDA; the test labels are unknown.

# 1. Dataset structure, quality checks, and exact duplicates
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

eda_train_pixels = df[PIXEL_COLS].to_numpy(dtype=np.uint8).reshape(-1, 28, 28)
eda_test_pixels = test_df[PIXEL_COLS].to_numpy(dtype=np.uint8).reshape(-1, 28, 28)

summary = pd.DataFrame({
    'Train': [len(df), len(PIXEL_COLS), df.isna().sum().sum(), df['ID'].duplicated().sum(),
              df.duplicated(subset=PIXEL_COLS).sum()],
    'Test': [len(test_df), len(PIXEL_COLS), test_df.isna().sum().sum(),
             test_df['ID'].duplicated().sum(), test_df.duplicated(subset=PIXEL_COLS).sum()]
}, index=['Images', 'Pixel features', 'Missing values', 'Duplicate IDs', 'Duplicate images'])
display(summary)

assert df['label'].dropna().isin(range(10)).all(), 'Unexpected class labels'
assert df[PIXEL_COLS].notna().all().all() and test_df[PIXEL_COLS].notna().all().all()
assert eda_train_pixels.min() >= 0 and eda_train_pixels.max() <= 255
assert eda_test_pixels.min() >= 0 and eda_test_pixels.max() <= 255

train_image_keys = {img.tobytes() for img in eda_train_pixels}
train_test_overlap = sum(img.tobytes() in train_image_keys for img in eda_test_pixels)
print(f'Pixel range: train [{eda_train_pixels.min()}, {eda_train_pixels.max()}] | '
      f'test [{eda_test_pixels.min()}, {eda_test_pixels.max()}]')
print(f'Exact competition train/test image matches: {train_test_overlap}/{len(test_df)}')
Train Test
Images 10000 2000
Pixel features 784 784
Missing values 0 0
Duplicate IDs 0 0
Duplicate images 0 0
Pixel range: train [0, 255] | test [0, 255]
Exact competition train/test image matches: 0/2000
# 2. Class balance in labeled training data
class_counts = df['label'].value_counts().reindex(range(10), fill_value=0).sort_index()
class_table = pd.DataFrame({
    'Class': CLASS_NAMES, 'Count': class_counts.to_numpy(),
    'Share (%)': (100 * class_counts / len(df)).round(2).to_numpy()
})
display(class_table)
fig, ax = plt.subplots(figsize=(11, 3.5))
ax.bar(CLASS_NAMES, class_counts.to_numpy())
ax.set(title='Training samples per class', ylabel='Count')
ax.tick_params(axis='x', rotation=40)
plt.tight_layout()
plt.show()
Class Count Share (%)
0 T-shirt/top 1003 10.03
1 Trouser 972 9.72
2 Pullover 969 9.69
3 Dress 1037 10.37
4 Coat 1014 10.14
5 Sandal 961 9.61
6 Shirt 1044 10.44
7 Sneaker 1003 10.03
8 Bag 943 9.43
9 Ankle boot 1054 10.54
# 3. Two representative images from each class
rng = np.random.default_rng(42)
fig, axes = plt.subplots(2, 10, figsize=(16, 4.1))
train_labels = df['label'].to_numpy()
for label in range(10):
    candidates = np.flatnonzero(train_labels == label)
    if len(candidates) == 0:
        for row in range(2): axes[row, label].axis('off')
        continue
    picks = rng.choice(candidates, size=2, replace=len(candidates) < 2)
    for row, sample_i in enumerate(picks):
        axes[row, label].imshow(eda_train_pixels[sample_i], cmap='gray', vmin=0, vmax=255)
        axes[row, label].axis('off')
        if row == 0: axes[row, label].set_title(CLASS_NAMES[label], fontsize=9)
fig.suptitle('Examples from the labeled training set', fontsize=12)
plt.tight_layout()
plt.show()
# 5. Average image for each category (visual similarity / class confusion)
fig, axes = plt.subplots(2, 5, figsize=(10.5, 5.4))
for i, ax in enumerate(axes.flat):
    imgs = eda_train_pixels[train_labels == i]
    if len(imgs):
        ax.imshow(imgs.mean(axis=0), cmap='gray', vmin=0, vmax=255)
    ax.set_title(CLASS_NAMES[i], fontsize=10)
    ax.axis('off')
plt.suptitle('Mean training image by class', y=1.01)
plt.tight_layout()
plt.show()
images = train_df[pixel_cols].to_numpy(dtype=np.float32).reshape(-1, 1, 28, 28) / 255
labels = train_df["label"].to_numpy(dtype=np.int64)

x_train, x_val, y_train, y_val = train_test_split(
    images, labels, test_size=0.2, random_state=42, stratify=labels
)
x_train = torch.from_numpy(x_train)
y_train = torch.from_numpy(y_train)
x_val = torch.from_numpy(x_val)
y_val = torch.from_numpy(y_val)
x_test = torch.from_numpy(
    test_df[pixel_cols].to_numpy(dtype=np.float32).reshape(-1, 1, 28, 28) / 255
)

class FashionDataset(Dataset):
    def __init__(self, images, labels, augmentation=None):
        self.images = images
        self.labels = labels
        self.augmentation = augmentation

    def __len__(self):
        return len(self.labels)

    def __getitem__(self, index):
        image = self.images[index]
        if self.augmentation is not None:
            image = self.augmentation(image)
        return image, self.labels[index]


def predict(model, images):
    model.eval()
    predictions = []
    loader = DataLoader(images, batch_size=256, shuffle=False)
    with torch.no_grad():
        for batch in loader:
            scores = model(batch.to(device))
            predictions.append(torch.softmax(scores, dim=1).cpu().numpy())
    return np.concatenate(predictions)


def train_model(architecture, name, epochs, augmentation=None,
                optimizer="Adam", cosine=False, seed=42):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)

    model = architecture().to(device)
    loader = DataLoader(
        FashionDataset(x_train, y_train, augmentation),
        batch_size=64, shuffle=True
    )
    if optimizer == "AdamW":
        opt = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=1e-4)
    else:
        opt = torch.optim.Adam(model.parameters(), lr=0.001)

    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
        opt, T_max=epochs, eta_min=1e-5
    ) if cosine else None

    best_accuracy = -1
    best_epoch = 0
    checkpoint = output_dir / f"{name}.pth"

    for epoch in range(epochs):
        model.train()
        total_loss = 0
        for batch, targets in loader:
            batch, targets = batch.to(device), targets.to(device)
            opt.zero_grad()
            outputs = model(batch)
            loss = nn.functional.cross_entropy(outputs, targets)
            loss.backward()
            opt.step()
            total_loss += loss.item() * len(targets)

        if scheduler is not None:
            scheduler.step()

        accuracy = (predict(model, x_val).argmax(axis=1) == y_val.numpy()).mean()
        if accuracy > best_accuracy:
            best_accuracy = accuracy
            best_epoch = epoch + 1
            torch.save(model.state_dict(), checkpoint)

        if (epoch + 1) % 10 == 0 or epoch == 0 or epoch + 1 == epochs:
            print(f"{name}: epoch {epoch + 1}/{epochs}, "
                  f"loss {total_loss / len(y_train):.4f}, val {accuracy:.4f}")

    model.load_state_dict(torch.load(checkpoint, map_location=device, weights_only=True))
    return model, best_accuracy, best_epoch


scores = {}
submission_files = []

def submit(model_name, probabilities, val_accuracy):
    path = output_dir / f"{model_name}.csv"
    pd.DataFrame({"ID": test_df["ID"], "label": probabilities.argmax(axis=1)}).to_csv(
        path, index=False
    )
    submission_files.append(path)
    scores[model_name] = val_accuracy
    print(f"Saved {path} (validation: {val_accuracy:.4f})")


class BiggerCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
            nn.Conv2d(32, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
            nn.MaxPool2d(2), nn.Dropout2d(0.10),
            nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
            nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
            nn.MaxPool2d(2), nn.Dropout2d(0.15),
            nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
            nn.MaxPool2d(2)
        )
        self.classifier = nn.Sequential(
            nn.Flatten(), nn.Linear(128 * 3 * 3, 128), nn.ReLU(),
            nn.Dropout(0.4), nn.Linear(128, 10)
        )

    def forward(self, x):
        return self.classifier(self.features(x))


original_aug = transforms.Compose([
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomAffine(degrees=10, translate=(0.08, 0.08), scale=(0.95, 1.05)),
    transforms.RandomErasing(p=0.15, scale=(0.02, 0.08), ratio=(0.5, 2.0))
])

light_aug = transforms.Compose([
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomAffine(degrees=6, translate=(0.04, 0.04), scale=(0.97, 1.03))
])

1. SimpleCNN — two convolutional layers and max pooling

class SimpleCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 32, 3)
        self.conv2 = nn.Conv2d(32, 64, 3)
        self.pool = nn.MaxPool2d(2)
        self.fc1 = nn.Linear(64 * 5 * 5, 128)
        self.fc2 = nn.Linear(128, 10)

    def forward(self, x):
        x = self.pool(torch.relu(self.conv1(x)))
        x = self.pool(torch.relu(self.conv2(x)))
        return self.fc2(torch.relu(self.fc1(x.flatten(1))))

simple_model, simple_acc, _ = train_model(
    SimpleCNN, "00_simple_cnn", epochs=20, augmentation=original_aug
)
submit("00_simple_cnn", predict(simple_model, x_test), simple_acc)

2. BiggerCNN — deeper CNN with batch normalization and dropout

bigger_model, bigger_acc, _ = train_model(
    BiggerCNN, "01_bigger_baseline", epochs=75, augmentation=original_aug
)
submit("01_bigger_baseline", predict(bigger_model, x_test), bigger_acc)

3. BiggerCNN + AdamW and cosine learning-rate scheduling

adamw_model, adamw_acc, _ = train_model(
    BiggerCNN, "02_bigger_adamw_cosine", epochs=75,
    augmentation=original_aug, optimizer="AdamW", cosine=True
)
submit("02_bigger_adamw_cosine", predict(adamw_model, x_test), adamw_acc)

4. BiggerCNN + light rotation and translation augmentation

light_model, light_acc, _ = train_model(
    BiggerCNN, "03_bigger_light_aug", epochs=75,
    augmentation=light_aug, optimizer="AdamW", cosine=True
)
submit("03_bigger_light_aug", predict(light_model, x_test), light_acc)

5. BiggerCNN ensemble — average predictions from three seeds

candidates = [
    (bigger_acc, bigger_model, original_aug, "Adam"),
    (adamw_acc, adamw_model, original_aug, "AdamW"),
    (light_acc, light_model, light_aug, "AdamW")
]
best_acc, best_model, best_augmentation, best_optimizer = max(candidates, key=lambda item: item[0])
print("Best BiggerCNN validation accuracy:", round(best_acc, 4))

ensemble = [best_model]
for seed in [13, 37]:
    model, accuracy, _ = train_model(
        BiggerCNN, f"04_bigger_seed_{seed}", epochs=75,
        augmentation=best_augmentation, optimizer=best_optimizer,
        cosine=(best_optimizer == "AdamW"), seed=seed
    )
    ensemble.append(model)

ensemble_val = np.mean([predict(model, x_val) for model in ensemble], axis=0)
ensemble_test = np.mean([predict(model, x_test) for model in ensemble], axis=0)
ensemble_acc = (ensemble_val.argmax(axis=1) == y_val.numpy()).mean()
submit("04_bigger_seed_ensemble", ensemble_test, ensemble_acc)

6. ResNet — residual-block architecture for comparison

class ResidualBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Conv2d(in_channels, out_channels, 3, stride, padding=1, bias=False),
            nn.BatchNorm2d(out_channels), nn.ReLU(),
            nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False),
            nn.BatchNorm2d(out_channels)
        )
        self.skip = (nn.Identity() if in_channels == out_channels and stride == 1
                     else nn.Sequential(
                         nn.Conv2d(in_channels, out_channels, 1, stride, bias=False),
                         nn.BatchNorm2d(out_channels)))

    def forward(self, x):
        return torch.relu(self.layers(x) + self.skip(x))


class SmallResNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Conv2d(1, 32, 3, padding=1, bias=False),
            nn.BatchNorm2d(32), nn.ReLU(),
            ResidualBlock(32, 32),
            ResidualBlock(32, 64, stride=2),
            ResidualBlock(64, 128, stride=2),
            nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(128, 10)
        )

    def forward(self, x):
        return self.layers(x)


resnet_model, resnet_acc, _ = train_model(
    SmallResNet, "05_resnet_challenger", epochs=50,
    augmentation=light_aug, optimizer="AdamW", cosine=True
)
submit("05_resnet_challenger", predict(resnet_model, x_test), resnet_acc)

Download Solution.ipynb

Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.