Checklist Colombia AI Olympiad 2025 Final Round · Task 3
SOTA-AI Editorial
The Five Artists
Identify which of five artists drew each illustration.
Hints
Hint 1
Confirm the mapping and counts for all five artist IDs before training; preserve image IDs for submission.
Hint 2
Compare class counts and inspect representative images per artist rather than only the first few rows.
Hint 3
Use a stratified validation split and preserve spatial structure with a CNN.
Hint 4
Track validation accuracy, then refit on all 10,000 labeled images using the chosen epoch count and match the submission column names exactly.
The solution in one line
Learn the five subtle drawing styles with a convolutional classifier; the complete training pipeline is included, but the accuracy remains unverified because the image files could not be downloaded.
The editorial
The Five Artists — Colombia AI Olympiad 2025 Final
Problem domain: Computer vision, supervised five-class image classification
Official metric: Accuracy
Abridged statement
Train a model to identify which of five artist siblings drew each illustration. Their styles are similar, so the classifier must learn subtle visual differences. Predict an artist ID from 0 through 4 for each image in the test set. The original statement is in Spanish; the SOTA page provides the task summary and notebook.
Dataset analysis and EDA
The official notebook loads eleon360/five-artists-dataset and eleon360/five-artists-test-dataset from Hugging Face. The dataset viewer reports 10,000 labeled training images at 256×256 pixels, with integer artist_id labels from 0 to 4; the test dataset has 2,000 images with IDs 10,000–11,999. These counts and dimensions come from the training dataset viewer and test dataset viewer.
The official statement says the artists' styles differ only in small personal details. SOTA's inspection of the viewer supports image width/height 256 and five label IDs. Exact class counts, pixel statistics, duplicates, and per-class image grids were not measured here because the 113MB train parquet and test image collection could not be downloaded from this execution environment.
Experiments
| Experiment | Result |
|---|---|
| Official notebook baseline: flattened 32×32 RGB images, dense network, five epochs | Organizer-provided baseline; no reported held-out accuracy available |
| Stratified 85/15 validation with the residual CNN in this repo | Not run: image assets could not be downloaded; no measured accuracy |
| Kaggle public re-run submission | Not submitted; the listed public run ended 15 January 2026 |
No score is inferred from training-set accuracy or from Kaggle leaderboard status.
Chosen method
The runnable solution uses a small residual CNN operating on 128×128 RGB images. It applies modest rotation and color jitter, keeps horizontal reflection off to preserve any direction-dependent artist details, and optimizes cross-entropy with label smoothing. A stratified 15% holdout selects the best epoch by validation accuracy; the model then trains on all labeled images for that number of epochs and writes image_id,artist_id predictions.
The script defaults to 128-pixel inputs to keep GPU and CPU cost manageable while preserving more detail than the official baseline's 32×32 flattening. It prints class counts and per-epoch accuracy/macro-F1 when run, so its local validation result is reproducible.
Score and provenance
Measured score: none. Accuracy is the task metric, reported by the SOTA task page. The local data-dependent score was not measured because the Hugging Face image files were not accessible from this execution environment. The official notebook does not provide a validation result in the published summary. No 100% or leaderboard score is claimed.
Implementation and diagnostics
The script downloads the official Hugging Face splits at runtime, checks that labels are in {0,1,2,3,4}, makes a stratified validation split, prints per-class validation results, then writes a submission CSV using the public rerun's image_id and artist_id columns. The original starter notebook instead writes predicted_artist; check the active competition's sample submission before submitting. The public Kaggle rerun is now closed, so the output is useful for local validation or another authorized evaluation.
The translated notebook text says “convolutional neural network,” but its starter baseline actually flattens each 32×32 image and uses dense layers. The chosen method uses convolutional spatial structure, which better preserves local strokes and motifs.
Compute and footprint
Training uses PyTorch and torchvision. A CUDA GPU is recommended for the 10,000 images; the organizer explicitly mentions a T4 runtime for larger neural networks. CPU inference and training are supported, though no local runtime was measured. No pretrained weights or image assets are included in this repository.
Alternatives considered
- The organizer's small fully connected baseline is easy to run but discards spatial neighborhood structure after flattening.
- A pretrained ResNet/EfficientNet could help if weights are allowed and available, but no such result was tested here.
- Higher resolution, test-time augmentation, and a model ensemble are possible follow-ups, but should be selected using stratified validation rather than assumed to help.
- Horizontal flipping may be harmful if the artists use direction-specific strokes or compositions, so it is omitted by default.
These alternatives were not measured on the unavailable image data.
Sources and reuse
- SOTA task page and translated notebook
- Official Spanish statement/in-contest Kaggle page
- Public Kaggle re-run
- Labeled training dataset
- Unlabeled test dataset
The SOTA page says reuse is subject to the Kaggle competition rules. The Hugging Face card does not state a specific dataset license. This repository links both sources but does not redistribute the images or notebook.
Code
Solution code
#!/usr/bin/env python3
"""Train a CNN and write a Kaggle submission for Colombia's Five Artists task.
The script downloads the official Hugging Face datasets. It first uses a
stratified holdout to estimate accuracy and select the epoch count, then trains
on all labeled examples before predicting the 2,000 test images.
"""
from __future__ import annotations
import argparse
import copy
import random
from pathlib import Path
import numpy as np
import pandas as pd
import torch
from PIL import Image
from sklearn.metrics import accuracy_score, classification_report, f1_score
from sklearn.model_selection import train_test_split
from torch import nn
from torch.utils.data import DataLoader, Dataset
from torchvision import transforms
TRAIN_DATASET = "eleon360/five-artists-dataset"
TEST_DATASET = "eleon360/five-artists-test-dataset"
def seed_everything(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
class ArtistImages(Dataset):
def __init__(self, source, indices, size: int, augment: bool):
self.source = source
self.indices = list(map(int, indices))
base = [transforms.Resize((size, size), antialias=True)]
if augment:
base.extend(
[
transforms.RandomRotation(degrees=6),
transforms.ColorJitter(brightness=0.10, contrast=0.10, saturation=0.08, hue=0.02),
]
)
base.extend(
[
transforms.ToTensor(),
transforms.Normalize(mean=(0.5, 0.5, 0.5), std=(0.25, 0.25, 0.25)),
]
)
self.transform = transforms.Compose(base)
def __len__(self) -> int:
return len(self.indices)
def __getitem__(self, item: int):
row = self.source[self.indices[item]]
image = row["image"]
if not isinstance(image, Image.Image):
image = Image.fromarray(np.asarray(image))
image = image.convert("RGB")
tensor = self.transform(image)
if "artist_id" in row:
return tensor, int(row["artist_id"])
return tensor, int(row["image_id"])
class ResidualBlock(nn.Module):
def __init__(self, in_channels: int, out_channels: int, stride: int):
super().__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
self.skip = (
nn.Identity()
if stride == 1 and in_channels == out_channels
else nn.Sequential(
nn.Conv2d(in_channels, out_channels, 1, stride=stride, bias=False),
nn.BatchNorm2d(out_channels),
)
)
self.activation = nn.ReLU(inplace=True)
def forward(self, x):
residual = self.skip(x)
x = self.activation(self.bn1(self.conv1(x)))
x = self.bn2(self.conv2(x))
return self.activation(x + residual)
class ArtistCNN(nn.Module):
def __init__(self, classes: int = 5):
super().__init__()
self.stem = nn.Sequential(
nn.Conv2d(3, 48, 3, padding=1, bias=False),
nn.BatchNorm2d(48),
nn.ReLU(inplace=True),
)
self.features = nn.Sequential(
ResidualBlock(48, 64, 2),
ResidualBlock(64, 96, 2),
ResidualBlock(96, 160, 2),
ResidualBlock(160, 256, 2),
)
self.pool = nn.AdaptiveAvgPool2d(1)
self.head = nn.Sequential(nn.Flatten(), nn.Dropout(0.30), nn.Linear(256, classes))
def forward(self, x):
return self.head(self.pool(self.features(self.stem(x))))
def run_training(
model: nn.Module,
train_loader: DataLoader,
valid_loader: DataLoader | None,
device: torch.device,
max_epochs: int,
patience: int,
) -> tuple[nn.Module, int, dict[str, float] | None]:
criterion = nn.CrossEntropyLoss(label_smoothing=0.04)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=max_epochs)
best_state = copy.deepcopy(model.state_dict())
best_epoch = max_epochs
best_accuracy = -1.0
best_metrics = None
stale = 0
for epoch in range(1, max_epochs + 1):
model.train()
total_loss = 0.0
for images, labels in train_loader:
images = images.to(device, non_blocking=True)
labels = labels.to(device, dtype=torch.long, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
logits = model(images)
loss = criterion(logits, labels)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()
total_loss += float(loss.detach()) * len(labels)
scheduler.step()
mean_loss = total_loss / max(len(train_loader.dataset), 1)
if valid_loader is None:
print(f"epoch={epoch:02d}/{max_epochs} train_loss={mean_loss:.4f}")
best_state = copy.deepcopy(model.state_dict())
continue
y_true, y_pred = predict(model, valid_loader, device)
accuracy = accuracy_score(y_true, y_pred)
macro_f1 = f1_score(y_true, y_pred, average="macro", zero_division=0)
print(f"epoch={epoch:02d} train_loss={mean_loss:.4f} valid_accuracy={accuracy:.5f} macro_f1={macro_f1:.5f}")
if accuracy > best_accuracy:
best_accuracy = float(accuracy)
best_epoch = epoch
best_state = copy.deepcopy(model.state_dict())
best_metrics = {"accuracy": float(accuracy), "macro_f1": float(macro_f1)}
stale = 0
else:
stale += 1
if epoch >= 8 and stale >= patience:
break
model.load_state_dict(best_state)
return model, best_epoch, best_metrics
@torch.no_grad()
def predict(model: nn.Module, loader: DataLoader, device: torch.device) -> tuple[list[int], list[int]]:
model.eval()
targets: list[int] = []
predictions: list[int] = []
for images, labels in loader:
logits = model(images.to(device, non_blocking=True))
predictions.extend(logits.argmax(dim=1).cpu().tolist())
targets.extend(torch.as_tensor(labels).cpu().tolist())
return targets, predictions
@torch.no_grad()
def predict_test(model: nn.Module, loader: DataLoader, device: torch.device) -> tuple[list[int], list[int]]:
model.eval()
image_ids: list[int] = []
predictions: list[int] = []
for images, ids in loader:
logits = model(images.to(device, non_blocking=True))
predictions.extend(logits.argmax(dim=1).cpu().tolist())
image_ids.extend(torch.as_tensor(ids).cpu().tolist())
return image_ids, predictions
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--train-dataset", default=TRAIN_DATASET)
parser.add_argument("--test-dataset", default=TEST_DATASET)
parser.add_argument("--output", type=Path, default=Path("submission.csv"))
parser.add_argument("--checkpoint", type=Path, default=Path("artist_cnn.pt"))
parser.add_argument("--size", type=int, default=128)
parser.add_argument("--batch-size", type=int, default=64)
parser.add_argument("--epochs", type=int, default=30)
parser.add_argument("--patience", type=int, default=6)
parser.add_argument("--workers", type=int, default=2)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--prediction-column", default="artist_id", help="Use the Kaggle data-page target column")
args = parser.parse_args()
from datasets import load_dataset
seed_everything(args.seed)
train_data = load_dataset(args.train_dataset, split="train")
test_data = load_dataset(args.test_dataset, split="train")
labels = np.asarray(train_data["artist_id"], dtype=np.int64)
if not set(np.unique(labels)).issubset(set(range(5))):
raise ValueError("Expected artist_id labels 0 through 4")
counts = np.bincount(labels, minlength=5)
print(f"train={len(train_data):,}; test={len(test_data):,}; image=256x256 per Hugging Face viewer")
print(f"artist_id class counts={counts.tolist()}")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"device={device}")
indices = np.arange(len(train_data))
train_idx, valid_idx = train_test_split(
indices, test_size=0.15, random_state=args.seed, stratify=labels
)
pin = device.type == "cuda"
train_loader = DataLoader(
ArtistImages(train_data, train_idx, args.size, augment=True),
batch_size=args.batch_size,
shuffle=True,
num_workers=args.workers,
pin_memory=pin,
)
valid_loader = DataLoader(
ArtistImages(train_data, valid_idx, args.size, augment=False),
batch_size=args.batch_size,
shuffle=False,
num_workers=args.workers,
pin_memory=pin,
)
model = ArtistCNN().to(device)
model, best_epoch, metrics = run_training(
model, train_loader, valid_loader, device, args.epochs, args.patience
)
y_valid, y_valid_hat = predict(model, valid_loader, device)
print(f"best_epoch={best_epoch}; holdout_accuracy={accuracy_score(y_valid, y_valid_hat):.6f}")
print(f"holdout_macro_f1={f1_score(y_valid, y_valid_hat, average='macro', zero_division=0):.6f}")
print(classification_report(y_valid, y_valid_hat, labels=list(range(5)), zero_division=0))
# Refit on all labeled images for the number of epochs selected by holdout accuracy.
seed_everything(args.seed)
full_loader = DataLoader(
ArtistImages(train_data, indices, args.size, augment=True),
batch_size=args.batch_size,
shuffle=True,
num_workers=args.workers,
pin_memory=pin,
)
final_model = ArtistCNN().to(device)
final_model, _, _ = run_training(final_model, full_loader, None, device, best_epoch, args.patience)
args.checkpoint.parent.mkdir(parents=True, exist_ok=True)
torch.save(
{"state_dict": final_model.state_dict(), "size": args.size, "best_epoch": best_epoch, "seed": args.seed},
args.checkpoint,
)
test_loader = DataLoader(
ArtistImages(test_data, np.arange(len(test_data)), args.size, augment=False),
batch_size=args.batch_size,
shuffle=False,
num_workers=args.workers,
pin_memory=pin,
)
image_ids, test_predictions = predict_test(final_model, test_loader, device)
output = pd.DataFrame({"image_id": image_ids, args.prediction_column: test_predictions})
output.to_csv(args.output, index=False)
print(f"wrote {len(output):,} predictions to {args.output}")
if __name__ == "__main__":
main()
Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.