← Back to the problem

Singapore team selection 2024 · Correction by IOAI Training

IOAI Final Selection Test (Computer Vision)

This page gives the full approach. Read it once you have searched.

Metric
test accuracy per question
Evaluated on
official test
Our score
Q1.5 95.2 %, Q2.5 93.9 % accuracy
Expected level
full marks at 96 % (Q1.5) and 95 % (Q2.5)

Our solution does not reach the expected level: there is still room to beat it

We solved the problem ourselves: downloaded the data, ran the experiments and measured the score below. The statement and data belong to their organisers.

The problem in brief

This camp test has two parts built on CIFAR-10 (60,000 color images of 32x32 pixels, 10 classes) and a ResNet-18 given in the notebook. Task 1 compares ways of using pretrained knowledge: finetune an ImageNet ResNet-18, train the same network from scratch, classify with CLIP without any training (zero-shot), improve CLIP with a prompt ensemble, then learn the class "text" vectors on the training set. Task 2 builds a deliberately imbalanced training set (100 % of airplanes and cars down to 2 % of ships and trucks) and asks you to diagnose and fix the damage: per-class precision and recall, balanced sampling, loss weighting, and a free final push. The CIFAR-10 test set and its labels are public, so every number below is measured on the official 10,000 test images. The notebook comes from a third-party repository (github.com/teodora-dimitrova/Singapore-Team-Selection); the copy there already contains one contestant's answers, which we did not use.

How you are scored

Each question has marks for code, accuracy and short reports, with the expected accuracy written next to it (Q1.1 about 88 %, Q1.2 about 55 %, Q1.3 about 90 %, Q1.4 about 91 %, Q2.1 about 69 %). Two questions have a formula:

  • Q1.5 (10 marks for accuracy): max⁡{min⁡{acc−9196,1}×10, iN×10}\max\{\min\{\tfrac{acc - 91}{96}, 1\} \times 10,\ \tfrac{i}{N} \times 10\}, where i/Ni/N is your relative rank. Taken literally, the fraction can never exceed 5/965/96; the intended formula is almost certainly acc−9196−91\tfrac{acc-91}{96-91}: zero marks at 91 %, full marks at 96 %.
  • Q2.5 (10 marks): min⁡{acc−8595,1}×10\min\{\tfrac{acc-85}{95}, 1\} \times 10, read the same way as acc−8595−85\tfrac{acc-85}{95-85}: full marks at 95 %.

First look at the setup

Before running anything, read the notebook's ResNet carefully, because it is not torchvision's.

  • The stem has stride 1 and no max-pool. A 32x32 image stays 32x32 through layer1, and the final feature map is 4x4. This is the usual CIFAR adaptation, and it makes the network about 16 times more expensive than torchvision's ResNet-18 on the same tiny input. The ImageNet 7x7 filters are still loaded, they just slide with a different stride.
  • The BatchNorm layers inside the blocks have track_running_stats=False. They always normalise with the statistics of the current batch, even in eval() mode. So the test accuracy depends on the test batches (100 images in file order): harmless here, but a trap if you ever predict one image at a time.
  • Loading pretrained weights with strict=False hides mismatches. Our check is to print the missing keys: they must be only fc.weight and fc.bias. The running-mean buffers of the untracked BatchNorms show up as "unexpected" keys and are rightly ignored.

We downloaded CIFAR-10 from the Hugging Face mirror uoft-cs/cifar10 (identical images) and loaded all pretrained weights from safetensors files.

Task 1: what pretraining buys you

Q1.1 and Q1.2, finetuning against training from scratch. With the notebook's settings (SGD, learning rate 0.01, momentum 0.9, weight decay 5×10−45\times10^{-4}, batch 128, crop and flip augmentation, one epoch), the ImageNet-initialised network reaches 89.7 % and the randomly initialised one 57.6 %. The pretrained filters already detect edges, textures and object parts, so one pass over CIFAR-10 is enough to adapt them; from scratch, one epoch only starts learning those filters. The price of finetuning: you inherit the pretrained architecture (here its 7x7 stem, designed for 224-pixel images), its input normalisation and its biases, and the gain shrinks when the target domain is far from ImageNet (medical scans, spectrograms).

Q1.3, CLIP zero-shot. CLIP embeds images and sentences in the same 512-dimensional space. To classify, embed the sentence "a photo of a {class}." for the 10 classes, normalise everything, and pick the class whose text vector has the largest cosine similarity with the image. With ViT-B/32 on the full test set: 88.3 %, with no training at all.

One technical lesson cost us 3 points before we found it. OpenAI trained CLIP with the QuickGELU activation, x σ(1.702 x)x\,\sigma(1.702\,x). Loading the same weights into a model that uses the exact GELU (open_clip's ViT-B-32 instead of ViT-B-32-quickgelu) runs without any error and gives 85.1 %. Weights only make sense with the exact architecture they were trained with.

Q1.4, prompt ensembling. Embed the class name in the 7 templates of the CLIP paper ("itap of a {}.", "a bad photo of the {}.", "a origami {}." and so on), normalise each embedding, average them per class and renormalise. Accuracy rises to 90.0 % (88.3 % with one prompt, 87.4 % with the bare class name). Why it works: each sentence's embedding is the class concept plus noise that depends on the wording ("origami" pulls towards paper, "video game" towards renders). Averaging over different wordings cancels that wording-specific noise and keeps the common direction, exactly as averaging several noisy measurements reduces the variance. It also covers more of the visual styles in which the class can appear. The notebook's own hint of "~90 %" and "~91 %" comes from evaluating on about 100 random images, where one image is worth a full percentage point.

Q1.5, learning the class vectors. Now we keep the image encoder frozen and make the 10 class vectors trainable. Computing CLIP features for every image once (batches of 250 on the GPU, 4 minutes for the training set) turns the problem into training a tiny model on 50,000 vectors of dimension 512:

logitc(x)=s⋅f(x)∥f(x)∥⋅wc∥wc∥,\text{logit}_c(x) = s \cdot \frac{f(x)}{\lVert f(x) \rVert} \cdot \frac{w_c}{\lVert w_c \rVert},

where f(x)f(x) is the frozen image feature, wcw_c the learnable vector of class cc, initialised with the prompt-ensemble embedding, and s=eτs = e^{\tau} a learnable temperature initialised at 100 and capped there, as in CLIP's own code. AdamW (learning rate 10−310^{-3}, cosine schedule, 30 epochs, batch 256). For augmentation in feature space we also computed the features of the mirrored images and trained on both views, then averaged both views at test time.

  • On a 5,000-image validation split carved out of the training set: 95.2 % (95.5 % with the flip average).
  • On the test set: 95.2 % with flip averaging (95.1 % without), so about 8.5 of the 10 accuracy marks with the intended formula.

Two observations for the report. The initialisation mostly buys speed: a random initialisation ends at 94.9 % on validation, but the text-initialised model is already at 90 % before the first step. And the learned vectors move far from the text: their cosine with the initial text embedding ends around 0.5, because the best classifier in image space is not the text direction, only a good starting point. Reaching 96 % with a frozen ViT-B/32 would need a stronger image encoder or finetuning the image tower.

Task 2: an imbalanced training set

Q2.1. Keeping each image of class ii with probability keep_ratio[i] (we fixed the random seed) leaves 18,763 images, from 5,000 airplanes and cars to 108 ships and 91 trucks. Finetuning for 3 epochs (about the same number of steps as one epoch on the full set) gives 69.9 % on the balanced test set.

Q2.2, precision and recall per class. For each class, count true positives, false positives (predicted cc, truly another class) and false negatives (truly cc, predicted another class); then precision =TP/(TP+FP)= TP/(TP+FP) and recall =TP/(TP+FN)= TP/(TP+FN).

Left: the baseline's precision and recall per class, with the number of training images under each name. Right: recall per class for each fix, and the overall accuracy.

The pattern is the signature of a prior learned from data. The frequent classes have high recall and low precision (airplane recall 0.98 but precision 0.56, automobile 1.00 and 0.45): the model says "airplane" or "car" whenever it hesitates, so it catches all of them and many intruders. The rare classes have the opposite profile (truck recall 0.17 with precision 0.97, ship 0.42 and 1.00): the model only says "truck" when it is very sure, so what it says is right but it misses most trucks. The confusion matrix shows where they go: 763 of the 1,000 test trucks are predicted as automobiles, and ships go to airplanes (332) and cars (224), the visually closest frequent classes. The reason is the cross-entropy itself. Minimising the average loss over a training set with 55 cars per truck learns p(class∣x)p(\text{class} \mid x) under the training prior; on a balanced test set, the decision boundaries sit too far into the territory of the rare classes.

Q2.3, balanced sampling. Draw a class uniformly, then an image of that class uniformly: every batch now looks balanced. Accuracy jumps to 85.9 % and the truck recall from 0.17 to 0.73. The cost: the 91 trucks are each seen about 20 times per epoch, so the model risks memorising them (augmentation helps).

Q2.4, weighting the loss. We tried three weightings of the cross-entropy (normalised to average 1 over classes) and one logit correction:

weightingtest accuracy
none (Q2.1)69.9 %
wc∝1/ncw_c \propto 1/n_c56.6 %
wc∝1/ncw_c \propto 1/\sqrt{n_c}74.3 %
effective number, wc∝(1−β)/(1−βnc)w_c \propto (1-\beta)/(1-\beta^{n_c}), β=0.999\beta = 0.99976.4 %
balanced softmax: train on zc+log⁡πcz_c + \log \pi_c82.7 %

Plain inverse frequency is the worst: each truck weighs 55 times a car, so a single truck in a batch dominates the gradient, the effective learning rate on rare classes explodes and training becomes noisy (the frequent classes' recall collapses to 0.4 to 0.5). The softer weightings help a little. The best option is not a weight at all. Balanced softmax (logit adjustment) adds log⁡πc\log \pi_c, the log of the training frequency, to each logit during training only. The network then learns logits zcz_c such that softmax⁡(z+log⁡π)\operatorname{softmax}(z + \log\pi) fits the imbalanced training data; since Bayes' rule says ptrain(c∣x)∝p(x∣c) πcp_{\text{train}}(c\mid x) \propto p(x\mid c)\,\pi_c, the learned zcz_c estimates log⁡p(x∣c)\log p(x \mid c) up to a constant, which is exactly the right score for a balanced test set. It corrects the prior without changing how often each image is seen.

Q2.5, the final push. Training longer helps everyone: the notebook's ResNet-18 with balanced softmax, label smoothing 0.1, a one-cycle schedule (10 epochs, peak learning rate 0.05) and flip averaging at test reaches 88.3 %. The big step, though, comes from using the pretrained network the way it was pretrained. An ImageNet ResNet-50 expects objects of 100 pixels or more; we upsample each 32x32 image to 128x128 on the GPU, keep torchvision's stem (stride 2 and max-pool), normalise with ImageNet's mean and standard deviation, and finetune for 6 epochs (SGD, one-cycle up to 0.02, batch 64, weight decay 10−410^{-4}, balanced softmax and label smoothing), then average each test image with its mirror. Result: 93.9 % (93.3 % without the flip), about 8.9 of the 10 marks. Every class has a recall of at least 0.89, trucks 0.98 and ships 0.97: with good features, 100 examples per class are enough, as long as the prior is corrected. This run took 12 minutes on a laptop GPU; a couple more epochs or 160-pixel inputs would likely close the remaining gap to 95 %.

Results

All on the official CIFAR-10 test set (10,000 images).

questionapproachaccuracynotebook's hint
Q1.1ImageNet ResNet-18 finetuned, 1 epoch89.7 %~88 %
Q1.2same network from scratch, 1 epoch57.6 %~55 %
Q1.3CLIP ViT-B/32 zero-shot, "a photo of a {}."88.3 %~90 %
same with GELU instead of QuickGELU (bug)85.1 %
Q1.47-prompt ensemble90.0 %~91 %
Q1.5learnable class vectors on frozen CLIP features, flip views95.2 %full marks at 96 %
Q2.1imbalanced set, 3 epochs69.9 %~69 %
Q2.3balanced sampling85.9 %
Q2.4best weighting: balanced softmax82.7 %
Q2.5ResNet-18, balanced softmax + LS, 10 epochs, flip TTA88.3 %
Q2.5ResNet-50 at 128 px, balanced softmax + LS, 6 epochs, flip TTA93.9 %full marks at 95 %

What to remember

  • Pretraining is the biggest lever, if you respect it. One epoch of finetuning beats training from scratch by 32 points, and feeding the ImageNet ResNet-50 images at a resolution close to what it was trained on was worth more than every imbalance trick together.
  • Weights come with an exact architecture. QuickGELU against GELU, a stem's stride, BatchNorm statistics, input normalisation: a mismatch rarely crashes, it silently costs accuracy. Check missing keys and sanity-check a known number.
  • Prompt ensembling is variance reduction. Averaging normalised text embeddings over several wordings cancels the wording-specific noise, and the result is an excellent initialisation for a learned linear head.
  • Imbalance shows up as a precision/recall asymmetry. Frequent classes get high recall and low precision, rare classes the reverse; look per class, never only at overall accuracy.
  • Correct the prior rather than reweighting blindly. Inverse-frequency weights make training unstable; balanced sampling or balanced softmax (zc+log⁡πcz_c + \log\pi_c during training) fix the boundary with far less damage.
Full code (solution.py)
"""Singapore IOAI 2024 camp test (CV): CIFAR-10 finetuning, CLIP, class imbalance.

usage: python solution.py data | task1 | clip | task2
Data: CIFAR-10 from the HF mirror uoft-cs/cifar10 (same images as the Toronto archive);
weights as safetensors (no pickles): timm/resnet18.tv_in1k and timm/resnet50.tv2_in1k (torchvision
ImageNet weights), timm/vit_base_patch32_clip_224.openai (open_clip_model.safetensors).
Runs on Apple MPS; every run is a few minutes except ResNet-50 (~15 min).
"""
import io, sys, time
import numpy as np, torch, torch.nn as nn, torch.nn.functional as F
from safetensors.torch import load_file
torch.set_num_threads(2); torch.manual_seed(0)
DEV = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
CLASSES = ["airplane", "automobile", "bird", "cat", "deer", "dog", "frog", "horse", "ship", "truck"]
KEEP = [1.0, 1.0, 0.5, 0.5, 0.25, 0.25, 0.1, 0.1, 0.02, 0.02]   # Q2.1 keep ratio per class

def make_data():
    import pandas as pd
    from PIL import Image
    for s in ["train", "test"]:
        d = pd.read_parquet(f"https://huggingface.co/datasets/uoft-cs/cifar10/resolve/main/plain_text/{s}-00000-of-00001.parquet")
        np.save(f"{s}_x.npy", np.stack([np.asarray(Image.open(io.BytesIO(b["bytes"])).convert("RGB")) for b in d.img]))
        np.save(f"{s}_y.npy", d.label.values.astype(np.int64))

def load_cifar():  # Normalize(0.5, 0.5) as in the notebook
    f = lambda x: (torch.from_numpy(x).permute(0, 3, 1, 2).float() / 255 - 0.5) / 0.5
    return f(np.load("train_x.npy")), torch.from_numpy(np.load("train_y.npy")), f(np.load("test_x.npy")), torch.from_numpy(np.load("test_y.npy"))

def augment(x):  # RandomCrop(32, padding=4) + RandomHorizontalFlip, batched on the device
    n, ar = x.shape[0], torch.arange(32, device=x.device)
    i, j = torch.randint(0, 9, (n, 1), device=x.device), torch.randint(0, 9, (n, 1), device=x.device)
    out = F.pad(x, (4, 4, 4, 4))[torch.arange(n, device=x.device)[:, None, None, None], torch.arange(3, device=x.device)[None, :, None, None],
                                 (i + ar)[:, None, :, None], (j + ar)[:, None, None, :]]
    flip = torch.rand(n, device=x.device) < 0.5
    out[flip] = out[flip].flip(3)
    return out

class Block(nn.Module):  # the notebook's BasicBlock: BatchNorm WITHOUT running statistics
    def __init__(s, cin, c, stride):
        super().__init__()
        s.conv1, s.bn1 = nn.Conv2d(cin, c, 3, stride, 1, bias=False), nn.BatchNorm2d(c, track_running_stats=False)
        s.conv2, s.bn2 = nn.Conv2d(c, c, 3, 1, 1, bias=False), nn.BatchNorm2d(c, track_running_stats=False)
        s.downsample = nn.Sequential(nn.Conv2d(cin, c, 1, stride, bias=False), nn.BatchNorm2d(c)) if stride != 1 else None
    def forward(s, x):
        out = s.bn2(s.conv2(F.relu(s.bn1(s.conv1(x)))))
        return F.relu(out + (x if s.downsample is None else s.downsample(x)))

class ResNet(nn.Module):  # the notebook's ResNet-18 (7x7 stem, stride 1, no max-pool, 4x4 average pool)
    def __init__(s):
        super().__init__()
        s.conv1, s.bn1 = nn.Conv2d(3, 64, 7, 1, 3, bias=False), nn.BatchNorm2d(64, track_running_stats=False)
        cfg = [(64, 64, 1), (64, 128, 2), (128, 256, 2), (256, 512, 2)]
        for k, (a, b, st) in enumerate(cfg, 1):
            setattr(s, f"layer{k}", nn.Sequential(Block(a, b, st), Block(b, b, 1)))
        s.fc = nn.Linear(512, 10)
    def forward(s, x):
        x = F.relu(s.bn1(s.conv1(x)))
        x = s.layer4(s.layer3(s.layer2(s.layer1(x))))
        return s.fc(F.avg_pool2d(x, 4).flatten(1))

def get_model(pretrained=True):  # Q1.1: load every ImageNet weight except fc
    m = ResNet()
    if pretrained:
        m.load_state_dict({k: v for k, v in load_file("r18.safetensors").items() if not k.startswith("fc.")}, strict=False)
    return m.to(DEV)

def train(m, X, y, epochs=1, lr=0.01, bs=128, loss_fn=F.cross_entropy, sampler=None, onecycle=False, wd=5e-4):
    opt = torch.optim.SGD(m.parameters(), lr=lr, momentum=0.9, weight_decay=wd)
    sch = torch.optim.lr_scheduler.OneCycleLR(opt, lr, total_steps=epochs * ((len(y) + bs - 1) // bs), pct_start=0.15) if onecycle else None
    for _ in range(epochs):
        m.train()
        idx = sampler(len(y)) if sampler else torch.randperm(len(y))
        for k in range(0, len(idx), bs):
            b = idx[k:k + bs]
            loss = loss_fn(m(augment(X[b].to(DEV))), y[b].to(DEV))
            opt.zero_grad(); loss.backward(); opt.step()
            if sch: sch.step()
    return m

@torch.no_grad()
def predict(m, X, tta=False):  # batches of 100 in file order, like the notebook's testloader
    m.eval(); out = []
    for k in range(0, len(X), 100):
        x = X[k:k + 100].to(DEV)
        p = m(x).softmax(1)
        out.append(((p + m(x.flip(3)).softmax(1)) / 2 if tta else p).cpu())
    return torch.cat(out)

def per_class(p, y):  # Q2.2: precision and recall per class from TP / FP / FN counts
    pred = p.argmax(1)
    tp = torch.stack([((pred == c) & (y == c)).sum() for c in range(10)]).float()
    fp = torch.stack([((pred == c) & (y != c)).sum() for c in range(10)]).float()
    fn = torch.stack([((pred != c) & (y == c)).sum() for c in range(10)]).float()
    return tp / (tp + fp).clamp(min=1), tp / (tp + fn)

acc = lambda p, y: (p.argmax(1) == y).float().mean().item() * 100

def task1():
    X, y, Xt, yt = load_cifar()
    print("Q1.1 finetuned, 1 epoch:", acc(predict(train(get_model(True), X, y), Xt), yt))
    print("Q1.2 from scratch, 1 epoch:", acc(predict(train(get_model(False), X, y), Xt), yt))

def clip_part():
    import open_clip
    from PIL import Image
    # QuickGELU matters: OpenAI's CLIP was trained with it (plain 'ViT-B-32' loses ~3 points)
    model, _, pre = open_clip.create_model_and_transforms("ViT-B-32-quickgelu", pretrained="clip_b32.safetensors")
    model, tok, nrm = model.eval().to(DEV), open_clip.get_tokenizer("ViT-B-32"), lambda v: v / v.norm(dim=-1, keepdim=True)

    @torch.no_grad()
    def img_feats(X, flip=False):
        return torch.cat([model.encode_image(torch.stack([pre(Image.fromarray(x[:, ::-1].copy() if flip else x)) for x in X[k:k + 250]]).to(DEV)).float().cpu()
                          for k in range(0, len(X), 250)])
    @torch.no_grad()
    def text_feats(templates):  # average of the normalised embeddings of each prompt (CLIP paper, 3.1.4)
        return nrm(torch.stack([nrm(model.encode_text(tok([t.format(c) for c in CLASSES]).to(DEV)).float().cpu()) for t in templates]).mean(0))
    Xtr, Xte = np.load("train_x.npy"), np.load("test_x.npy")
    ytr, yte = torch.from_numpy(np.load("train_y.npy")), torch.from_numpy(np.load("test_y.npy"))
    Fte, Ftr = img_feats(Xte), img_feats(Xtr)
    zero = lambda W: ((nrm(Fte) @ W.T).argmax(1) == yte).float().mean().item() * 100
    print("Q1.3 zero-shot 'a photo of a {}.':", zero(text_feats(["a photo of a {}."])))
    ens = text_feats(["itap of a {}.", "a bad photo of the {}.", "a origami {}.", "a photo of the large {}.",
                      "a {} in a video game.", "art of the {}.", "a photo of the small {}."])
    print("Q1.4 zero-shot, 7-prompt ensemble:", zero(ens))
    # Q1.5: learnable class embeddings initialised with the ensemble, cosine logits x temperature (capped at 100)
    W, logit_scale = nn.Parameter(ens.clone()), nn.Parameter(torch.tensor(np.log(100.0)))
    head = lambda f: logit_scale.exp().clamp(max=100) * nrm(f) @ nrm(W).T
    Ffl, Ftf = img_feats(Xtr, True), img_feats(Xte, True)        # mirrored views = augmentation in feature space
    X2, Y2 = torch.cat([Ftr, Ffl]), torch.cat([ytr, ytr])
    opt = torch.optim.AdamW([W, logit_scale], lr=1e-3, weight_decay=0.0)
    sch = torch.optim.lr_scheduler.CosineAnnealingLR(opt, 30 * (len(Y2) // 256 + 1))
    for _ in range(30):
        perm = torch.randperm(len(Y2))
        for k in range(0, len(Y2), 256):
            b = perm[k:k + 256]
            loss = F.cross_entropy(head(X2[b]), Y2[b]); opt.zero_grad(); loss.backward(); opt.step(); sch.step()
    with torch.no_grad():
        print("Q1.5 learnable prompts, flip TTA:", acc(head(Fte).softmax(1) + head(Ftf).softmax(1), yte))

def task2():
    X, y, Xt, yt = load_cifar()
    keep = np.random.default_rng(0).random(len(y)) < np.array(KEEP)[y.numpy()]
    Xi, yi = X[keep], y[keep]
    cnt = np.bincount(yi.numpy(), minlength=10); print("Q2.1 counts:", cnt.tolist())
    p = predict(train(get_model(), Xi, yi, epochs=3), Xt); print("Q2.1 baseline:", acc(p, yt))
    prec, rec = per_class(p, yt); print("Q2.2 precision", prec.numpy().round(2), "recall", rec.numpy().round(2))
    per = [torch.where(yi == c)[0] for c in range(10)]
    def balanced(n):  # Q2.3: pick a class uniformly, then an image of that class uniformly
        return torch.stack([per[c][torch.randint(len(per[c]), (1,))][0] for c in torch.randint(0, 10, (n,)).tolist()])
    print("Q2.3 balanced sampling:", acc(predict(train(get_model(), Xi, yi, epochs=3, sampler=balanced), Xt), yt))
    for name, w in [("1/n", 1 / cnt), ("1/sqrt(n)", 1 / np.sqrt(cnt)), ("effective number", (1 - 0.999) / (1 - 0.999 ** cnt))]:
        w = torch.tensor(w / w.sum() * 10, dtype=torch.float32, device=DEV)
        print(f"Q2.4 weighted CE {name}:", acc(predict(train(get_model(), Xi, yi, epochs=3, loss_fn=lambda o, t, w=w: F.cross_entropy(o, t, weight=w)), Xt), yt))
    logp = torch.log(torch.tensor(cnt / cnt.sum(), dtype=torch.float32, device=DEV))
    bal_softmax = lambda o, t: F.cross_entropy(o + logp, t, label_smoothing=0.1)   # train with prior-shifted logits
    print("Q2.4 balanced softmax:", acc(predict(train(get_model(), Xi, yi, epochs=3, loss_fn=lambda o, t: F.cross_entropy(o + logp, t)), Xt), yt))
    # Q2.5: ImageNet ResNet-50 at 128 px (its stem expects large images), balanced softmax + label smoothing, flip TTA
    import torchvision
    mean = torch.tensor([0.485, 0.456, 0.406], device=DEV).view(1, 3, 1, 1); std = torch.tensor([0.229, 0.224, 0.225], device=DEV).view(1, 3, 1, 1)
    class Up(nn.Module):
        def __init__(s):
            super().__init__(); s.net = torchvision.models.resnet50(); s.net.load_state_dict(load_file("r50.safetensors")); s.net.fc = nn.Linear(2048, 10)
        def forward(s, x):
            return s.net((F.interpolate(x * 0.5 + 0.5, size=(128, 128), mode="bilinear") - mean) / std)
    m = train(Up().to(DEV), Xi, yi, epochs=6, lr=0.02, bs=64, onecycle=True, loss_fn=bal_softmax, wd=1e-4)
    print("Q2.5 ResNet-50 @128px + balanced softmax, flip TTA:", acc(predict(m, Xt, tta=True), yt))

if __name__ == "__main__":
    {"data": make_data, "task1": task1, "clip": clip_part, "task2": task2}[sys.argv[1]]()