Discord

Checklist OAI 2024 Stage I · Task 2

SOTA-AI Editorial

Imbalanced Classification

Train a PyTorch convolutional network that separates noisy synthetic "normal" and "onion" shape images when the training set is imbalanced and the test set is balanced.

Written
8 October 2026
Result
100/100 in a local compact-CNN experiment; a clean standalone rerun and runtime check remain unverified.
Points
100/100 (see result for validation and provenance)
Olympiad
Polish Olympiad in Artificial Intelligence (Olimpiada Sztucznej Inteligencji), 2024 · Stage I

Hints

Hint 1

Compare class counts in the training set with the balanced validation set.

Hint 2

Which visual feature distinguishes onion images from otherwise similar noisy shapes?

Hint 3

Balance minibatches and use flips to avoid learning accidental orientation cues.

Hint 4

Pool spatial features before the classifier head to keep the parameter file small; return a probability if the grader thresholds at 0.5.

The solution in one line

Train a compact CNN with balanced sampling and flips, adaptive pooling, and a sigmoid output for the grader's 0.5 threshold.

The editorial

Imbalanced Classification — Stage I (2024)

Problem name: Imbalanced Classification (niezbalansowana_klasyfikacja)

Domain: Binary computer vision, imbalanced learning

Metric: Accuracy on a balanced normal/onion test set, converted to points by (round(accuracy, 2) - 0.5) × 2 above 50%, capped by the task at one point. Full credit is 100% validation accuracy.

Abridged statement

Train a PyTorch CNN to distinguish noisy light-gray shapes on black backgrounds from similar shapes with dark internal bands. The provided training set is imbalanced; validation/test data is balanced. The required interface has create_with_training and load, and the model must fit within 35 MB and run on CPU.

Dataset analysis and EDA

The mirror contains 1,800 labeled 224×224 grayscale training images: 1,400 normal and 400 onion. The released validation split has 100 images, balanced at 50 per class. The filenames encode labels (normal → 0, onion → 1); the normal examples show noisy filled shapes, while onion images add dark layered bands. The official GitHub notebook downloads the data at run time from Google Drive; the dataset itself is not stored in that repository. The validation inputs/labels used here came from the task-data mirror.

Experiments

  • Organizer dummy model: always predicts normal; its worked example records 50% balanced accuracy and zero points.
  • Mirror's worked CNN: two convolutional blocks with 32 and 64 channels, followed by a dense layer over all 64×56×56 features. Its checkpoint is 102,840,148 bytes (about 98 MiB), above the 35 MB task limit. The mirror notebook records 99% accuracy and 0.98 points.
  • Local reproduction of that checkpoint: using the starter's raw-logit comparison output > 0.5, accuracy was 98% (two onion false negatives). Comparing sigmoid(logit) > 0.5, equivalently using the standard logit > 0 decision boundary, gave 100% on the same split. The discrepancy demonstrates that the mirror checkpoint's raw logits are not calibrated to the grader's probability threshold.
  • Compact CNN: four convolutional blocks (24/48/72/96 channels), GroupNorm/GELU, adaptive 7×7 pooling, and a small dense head; weighted sampling balances the classes, random horizontal/vertical flips augment only training examples, and the output is a sigmoid probability. In the seeded local experiment this design reached 100% validation accuracy after each of the first three epochs. Training gradients used only the training split. An exact source rerun reproduced the first epoch's recorded training loss, then was stopped during epoch two because of shared-CPU runtime; that rerun did not finish training, validation, or checkpoint writing.

Selected solution

Use the compact CNN in imbalanced_classification.py. It returns probabilities so the starter grader's >0.5 comparison corresponds to logit>0. A weighted sampler corrects the 3.5:1 training imbalance; horizontal/vertical flips are applied only to training images. The model has under one million parameters and the code writes the required cnn-classifier.pth when run from the task directory. The generated checkpoint is not included here.

Validation score and evidence

The matching seeded compact-CNN experiment scored 100/100 after each of its first three epochs on the 100-image released validation split. This is 100% accuracy and one task point. The score is local evidence from the experiment, not from a completed run of the saved standalone source; the exact source rerun was interrupted during epoch two before validation. The result does not establish secret-test performance. On the same released split, the mirror's oversized checkpoint scored 98% under its raw-logit threshold, while the probability-corrected threshold scored 100%.

Compute and runtime

The compact experiment used CPU PyTorch with four threads, batch size 32, and three epochs. The task permits 15 minutes for training and 2 minutes for CPU inference on 50 images. The standalone source has under one million parameters, so its float32 parameter payload is under 4 MB and fits the 35 MB model-file limit; the exact serialized file size was not measured because the source rerun did not complete. End-to-end training and inference wall-clock times remain unverified in this shared CPU environment. Inference and all training used no GPU; training did not read validation labels.

Alternatives

The official worked convolutional architecture has a 25.7-million-parameter flattened dense layer and exceeds the checkpoint-size limit. Probability calibration fixes its local threshold mismatch but does not fix that size violation. Texture descriptors with an RBF SVM, HOG features, and smaller spatial pooling are alternatives not measured here.

Code

Solution code solution.py · 135 lines
"""Compact CNN for the 2024 Polish AI Olympiad Imbalanced Classification task.

Run from the task directory (containing ``train_data``) to train and save the
required ``cnn-classifier.pth`` file. Validation data is never read by training.
"""

from __future__ import annotations

import os
import random
from pathlib import Path

import numpy as np
from PIL import Image
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Dataset, WeightedRandomSampler


class CnnClassifier(nn.Module):
    MODEL_PATH = "cnn-classifier.pth"

    @classmethod
    def load(cls):
        """Load a state dict previously written by ``create_with_training``."""
        model = cls()
        try:
            state = torch.load(cls.MODEL_PATH, map_location="cpu", weights_only=True)
        except TypeError:  # Compatibility with older PyTorch used in Colab.
            state = torch.load(cls.MODEL_PATH, map_location="cpu")
        model.load_state_dict(state)
        return model.eval()

    @classmethod
    def create_with_training(cls):
        """Fit on ``train_data`` only, then save the compact model weights."""
        data_root = Path(os.environ.get("POLAND_2024_IMBALANCED_DATA_DIR", "."))
        train_dataset = _ImageDataset(data_root / "train_data", augment=True)
        counts = np.bincount(train_dataset.labels, minlength=2)
        if np.any(counts == 0):
            raise ValueError(f"Training data must contain both classes; counts={counts.tolist()}")

        seed = 42
        random.seed(seed)
        np.random.seed(seed)
        torch.manual_seed(seed)
        if torch.cuda.is_available():
            torch.cuda.manual_seed_all(seed)

        sample_weights = 1.0 / counts[train_dataset.labels]
        sampler = WeightedRandomSampler(
            torch.as_tensor(sample_weights, dtype=torch.double),
            num_samples=len(train_dataset),
            replacement=True,
        )
        loader = DataLoader(train_dataset, batch_size=32, sampler=sampler, num_workers=0)

        model = cls()
        device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        if device.type == "cpu":
            torch.set_num_threads(min(4, torch.get_num_threads()))
        model.to(device)
        optimizer = torch.optim.AdamW(model.parameters(), lr=8e-4, weight_decay=1e-4)
        criterion = nn.BCEWithLogitsLoss()

        # Three epochs reached 100% on the released split in the local run.
        # Keep the validation set out of the training loop.
        for epoch in range(3):
            model.train()
            total_loss = 0.0
            for images, labels in loader:
                images = images.to(device)
                labels = labels.to(device=device, dtype=torch.float32)
                optimizer.zero_grad(set_to_none=True)
                logits = model.logits(images).squeeze(1)
                loss = criterion(logits, labels)
                loss.backward()
                optimizer.step()
                total_loss += float(loss.detach())
            print(f"epoch {epoch + 1}/3: train_loss={total_loss / len(loader):.4f}")

        model.to("cpu").eval()
        torch.save(model.state_dict(), cls.MODEL_PATH)
        print(f"Saved {cls.MODEL_PATH} ({sum(p.numel() for p in model.parameters()):,} parameters)")
        return model


class _ImageDataset(Dataset):
    def __init__(self, directory: Path, augment: bool = False):
        if not directory.is_dir():
            raise FileNotFoundError(f"Image directory not found: {directory}")
        self.files = sorted(path for path in directory.glob("*") if path.is_file())
        self.labels = np.asarray(
            [0 if "normal" in path.name.lower() else 1 for path in self.files], dtype=np.int64
        )
        self.augment = augment
        if not self.files:
            raise ValueError(f"No training images found in {directory}")

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

    def __getitem__(self, index):
        with Image.open(self.files[index]) as image:
            pixels = np.asarray(image.convert("L"), dtype=np.float32) / 255.0
        x = torch.from_numpy(pixels[None, ...])
        if self.augment:
            if random.random() < 0.5:
                x = x.flip(-1)
            if random.random() < 0.5:
                x = x.flip(-2)
        return x, int(self.labels[index])


class YourCnnClassifier(CnnClassifier):
    """Task model. ``forward`` returns a probability for the starter's 0.5 test."""

    def __init__(self):
        super().__init__()
        width = 24
        self.features = nn.Sequential(
            nn.Conv2d(1, width, kernel_size=5, padding=2),
            nn.GroupNorm(4, width),
            nn.GELU(),
            nn.MaxPool2d(2),
            nn.Conv2d(width, width * 2, kernel_size=3, padding=1),
            nn.GroupNorm(8, width * 2),
            nn.GELU(),
            nn.MaxPool2d(2),
            nn.Conv2d(width * 2, width * 3, kernel_size=3, padding=1),
            nn.GroupNorm(8, width * 3),
            nn.GELU(),
            nn.MaxPool2d(2),
            nn.Conv2d(width * 3, width * 4, kernel_size=3, padding=1),
            nn.GroupNorm(8, width * 4),
            nn.GELU(),
            nn.MaxPool2d(2),
        )
        self.pool = nn.AdaptiveAvgPool2d(7)
        self.head = nn.Sequential(
            nn.Flatten(),
            nn.Dropout(0.25),
            nn.Linear(width * 4 * 7 * 7, 128),
            nn.GELU(),
            nn.Dropout(0.2),
            nn.Linear(128, 1),
        )

    def logits(self, x: torch.Tensor) -> torch.Tensor:
        return self.head(self.pool(self.features(x)))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # The task grader compares outputs to 0.5, so return probabilities,
        # while training uses logits with BCEWithLogitsLoss.
        return torch.sigmoid(self.logits(x))


if __name__ == "__main__":
    YourCnnClassifier.create_with_training()

Download solution.py

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