EVERYTHING AIAI engineering, made visual
0/28 complete
LESSON 04 · COMPUTER VISION × AI · BUILD

Pixels in. Probabilities out.
Everything else is plumbing.

A classifier is a function from 3,072 pixel values to a probability distribution over 10 classes. The model is the easy part. The dataset loop, the augmentation policy, the loss, the schedule and the evaluation are where the bugs live — and a broken pipeline quietly scores 70–75% where a correct one scores 93.45%.

75 MIN · 7 CHAPTERS + CHECKPREREQ · P2 L09 · P3 L10 · P4 L03
FIG. 04 / A LIVE PIPELINE · DATASET → AUGMENT → LOSS → EVALUATE
pixels → P(class) loss augmentation worst pair
LESSON 04TYPE · BUILD~75 MINPREREQ · PHASE 2 · LESSON 09 · PHASE 3 · LESSON 10 · PHASE 4 · LESSON 03ORIGINAL LESSON ↗
THE 60-SECOND VERSIONSee the problem ↓
01 / SIXTY THOUSAND TINY IMAGES

The dataset is half the model.

CIFAR-10 is 60,000 images of 32×32 pixels in 10 classes: 50,000 for training, 10,000 for testing, 6,000 per class. Each image is nothing but 3,072 numbers between 0 and 255. Every vision task in the phase — detection, segmentation, retrieval — reduces to putting those numbers into a class. Getting the loop right is the skill that transfers.

32 × 32 × 3 = 3,072 values · 50,000 train / 10,000 test
02 / THE PIPELINE IS THE MODEL

A correct CNN with a broken loop scores 70–75%.

Unshuffled batches, ImageNet stats on CIFAR, labels misaligned, softmax before the loss, augmentation left on at eval — none of these raise an error, and the loss curve looks plausible the whole time. A ResNet setup that should hit 93% quietly lands in the 70s. The fix is not a better architecture; it is reading each stage's numbers.

simple CNN 80–85% · ResNet 93–95% · broken pipeline 70–75%
03 / READ THE MATRIX, NOT THE AVERAGE

93.45% accuracy can hide a class at 0.870 recall.

Aggregate accuracy is one number; a confusion matrix is 100. Per-class precision asks how many flagged images were right; recall asks how many of the true images were found; F1 balances them. On the ResNet run, cat recall is 0.870 and ship recall is 0.965 — and the single worst pair, cat ↔ dog, costs 97 images in both directions.

P = TP/(TP+FP) · R = TP/(TP+FN) · F1 = 2PR/(P+R)
MENTAL MODEL IN ONE SENTENCE

A classifier is a function from 3,072 numbers to 10 probabilities: image [32,32,3] → logits [10] → softmax → loss. The architecture is a detail — the pipeline (dataset, augmentation, loss, optimizer, schedule, evaluation) is the deliverable, and each stage has one number that tells you whether it works.

By the end you will be able to name every stage of the CIFAR-10 pipeline and the bug it hides; predict a failure from a loss curve (flat at ln 10 = 2.3026 → labels, spike → learning rate, floor at 0.5448 → label smoothing is on, growing gap → overfitting); implement mixup, cutout and label smoothing from scratch and say why mixup raises the training floor to λ‑dependent values like 0.6109 at λ = 0.7; compute per-class precision, recall and F1 by hand from a confusion matrix; and recite the standard recipe — SGD momentum 0.9 or AdamW, cosine schedule, batch 128, dataset-specific normalization, and no augmentation at test time.

WHERE THE BUGS LIVE

The model is the easy part.
The pipeline is where it breaks.

Detection classifies regions. Segmentation classifies pixels. Retrieval ranks by similarity to class centroids. Every vision task in this phase reduces to image classification — and most classification bugs are not in the network. They are one line of plumbing, and nothing raises an error.

A null pointer throws. A broken classifier runs to completion, prints a loss, draws a curve, and predicts. The source states the stakes in one sentence: a CNN that would hit 93% on CIFAR-10 with a correct setup commonly scores 70–75% with a broken one — and the loss curve looks plausible the whole time. Unshuffled batches, a swapped preprocessing stat, a softmax in the wrong place: each costs roughly the same as deleting a quarter of your architecture, and none of them crash.

Before wiring anything, memorize the dataset’s arithmetic, because every later number is a check against it. CIFAR-10 is 60,000 images of 32×32 pixels in three colour channels, split 50,000 train / 10,000 test across 10 classes. One image is 3 × 32 × 32 = 3,072 values; the whole set is 184,320,000 values — about 184 MB of raw bytes, and it was deliberately chosen to be small. Chance-level cross-entropy for ten classes is ln 10 = 2.3026: a loss parked there is not learning slowly, it is predicting the uniform distribution.

CIFAR-10, by the numbers 60,000 images 50,000 train / 10,000 test 10 classes (6,000 each) 32 × 32 × 3 = 3,072 values per image uint8, 0 to 255 60,000 × 3,072 = 184,320,000 values ≈ 184 MB raw batch 128 50,000 / 128 = 390 full batches + 1 of 80 = 391 per epoch test pass 10,000 / 128 = 78 full batches + 1 of 16 = 79 batches accuracy bands to calibrate your eyes broken pipeline 70–75% the curve still looks fine simple CNN 80–85% this lesson's rule of thumb (VGG-style, batch norm, modest depth) ResNet 93–95% depth + residuals + the full recipe chance-level loss for C classes −log(1/C) = ln C 2 classes ln 2 = 0.6931 10 classes ln 10 = 2.3026

The lesson’s method is deliberately unglamorous: wire the whole pipeline by hand so every part is inspectable, and refuse to import anything from torchvision.datasets that could hide a bug. When the numbers are yours, a wrong one has an address. And when those labels are scarce, Lesson 05 (Transfer Learning & Fine-Tuning) starts from ImageNet weights instead of random ones.

ComponentClassic bugWhat you seeFirst check
Dataset / labelsimages and labels out of syncheld-out loss parked on ln 10 = 2.3026 from epoch 0print ten (image, label) pairs and look at them
Normalizationraw 0–255 pixels, or ImageNet stats on CIFARloss jumps to 12 → 41, then NaN by epoch 3recompute mean/std on the training split
DataLoadershuffle=False in trainingepoch-level sawtooth; converges late and noisilyshuffle=True for train, False for test
Modeloutput size ≠ classes; train()/eval() swappedaccuracy near chance, or dropout at test timeprint the output shape; assert the mode
Losssoftmax applied before cross-entropyloss stalls near 1.46 and never reaches the healthy bandpass raw logits to the loss
Optimizerlr above the stability limitspike to 9.4 by epoch 4, then permanent oscillationlower lr 10×; check gradient norms
Schedulerstepped per batch, or never steppednever settles, or freezes after a few epochsone scheduler.step() per epoch
Evaluationaugmentation left on at test timeheld-out accuracy several points below its potentialeval transform = normalize only, model.eval()
Regularizationno augmentation, no weight decaytrain → 0.03 while held-out bottoms at 0.80 and climbsaugmentation, weight decay, early stopping

Every row in this table is a curve shape the loss-board lab draws in chapter 03. The point of the table is order: nine components, nine signatures, and the cheapest check first — data, shapes, one batch, gradients, rate.

Quick check

A CIFAR-10 run finishes at 72% test accuracy and the loss curve looks plausible. Where is the bug most likely to be?

THE PIPELINE, STAGE BY STAGE

Data in. Probabilities out.
Ten stages, one shape walk.

Every stage in this pipeline transforms one tensor into another. If you know the shape, the dtype and the value range at each boundary, you can find almost any bug by walking the batch — which is what this chapter does, once, slowly.

The source’s pipeline is a straight line: dataset → augment → normalize → batch and shuffle → model → logits → cross-entropy → backward → optimizer step → scheduler step, back to the model. The evaluation pass takes the same line but skips augmentation, flips model.eval(), turns off gradients, and accumulates a confusion matrix instead of an update.

Stage 1: the dataset. One image arrives as a 32×32×3 uint8 array with one integer label. The source’s ArrayDataset keeps exactly that contract: __getitem__ returns (img, label) and never touches anything else, so the transform is the only place pixels change.

the dataset, with nothing hiddenpython
def standardize(mean, std):
    mean = np.array(mean, dtype=np.float32)
    std = np.array(std, dtype=np.float32)
    def _fn(img):
        return (img - mean) / std
    return _fn

def random_crop(pad=4):
    def _fn(img):
        h, w = img.shape[:2]
        # reflect, not zeros: black borders are a signal the model
        # would learn to ignore in a non-useful way
        padded = np.pad(img, ((pad, pad), (pad, pad), (0, 0)), mode="reflect")
        y = np.random.randint(0, 2 * pad + 1)
        x = np.random.randint(0, 2 * pad + 1)
        return padded[y:y + h, x:x + w, :]
    return _fn

train_tf = compose(random_hflip(), random_crop(pad=4), standardize(mean, std))
eval_tf  = standardize(mean, std)          # NO augmentation at test time
Adapted from python code/main.py. The two transforms every vision pipeline has: a pixel-level invariance (flip), a geometric one (crop), and normalization — applied in that order, then the tensor is permuted to [3, 32, 32].

Stages 2 and 3: augment, then normalize. The order matters: pixel transforms run on 0–1 floats, and normalization comes last so the statistics it subtracts are not perturbed by the augmentation. For CIFAR-10 the stats are dataset-specific — mean (0.4914, 0.4822, 0.4465), std (0.2470, 0.2435, 0.2616). Three pixels on channel 0 tell you where the values land:

(value/255 − mean) / std, channel 0 (mean 0.4914, std 0.2470) pixel 0 → (0.0000 − 0.4914) / 0.2470 = −1.989 pixel 128 → (0.5020 − 0.4914) / 0.2470 = +0.043 pixel 255 → (1.0000 − 0.4914) / 0.2470 = +2.059 paste in the ImageNet stats (mean 0.485, std 0.229) instead pixel 255 → (1.0000 − 0.4850) / 0.2290 = +2.249 ← 0.190 too high pixel 128 → +0.074 instead of +0.043 ← every step scaled why it matters: gradients scale with the input scale. Skip normalization entirely and the effective learning rate is ~255× the one you set.

Stage 4: batch and shuffle. The DataLoader groups images into batches of 128 and reshuffles every epoch. A batch tensor is [128, 3, 32, 32]: 128 × 3,072 = 393,216 float32 values = 1.5 MiB, and one epoch over 50,000 images is 391 batches — 390 full ones plus a final batch of 80. shuffle=False looks innocent and produces an epoch-level sawtooth: with class-ordered data every batch is a single class, so the loss alternates between “already seen” and “brand new” while convergence crawls.

Stages 5–6: forward pass to loss. The model maps [128, 3, 32, 32] to [128, 10] logits — ten unbounded real numbers per image. Cross-entropy turns those into one scalar using the fused log-softmax; at initialization it reads ln 10 = 2.3026. Stage 7–9: backward, step, schedule. Autograd fills a gradient per parameter, the optimizer applies step = lr × |grad| shaped by momentum, and the scheduler decays the rate once per epoch.

one step of the loop, with the invariants in placepython
def train_one_epoch(model, loader, optimizer, device, num_classes, use_mixup=True):
    model.train()                       # 1. train mode: dropout + batch norm live
    total, correct, loss_sum = 0, 0, 0.0
    for x, y in loader:
        x, y = x.to(device), y.to(device)
        if use_mixup:
            x_m, y_soft = mixup_batch(x, y, num_classes)
            logits = model(x_m)
            loss = soft_cross_entropy(logits, y_soft)
        else:
            logits = model(x)
            loss = nn.functional.cross_entropy(logits, y, label_smoothing=0.1)
        optimizer.zero_grad()           # 2. before backward, once per step
        loss.backward()
        optimizer.step()
        loss_sum += loss.item() * x.size(0)   # 3. .item() so no graph survives
        total += x.size(0)
        with torch.no_grad():           # argmax against raw logits
            pred = logits.argmax(dim=-1)
            correct += (pred == y).sum().item()
    return loss_sum / total, correct / total
Adapted from the source. The other two invariants live in evaluate(): @torch.no_grad() and model.eval(). Five lines, five invariant comments — every one of them is a bug someone shipped.

Walk one batch through the whole pipeline

Ten stages, from the files on disk to the confusion matrix. At each step: the shape the tensors take, the values they hold, and the bug that hides there — because almost every classification bug is a pipeline bug.

stage 1 / 10 · Dataset component images + labels in 60,000 files on disk out [32, 32, 3] uint8 dtype uint8 → float32 in the loader values 0 to 255 per channel · 3,072 values per image why it exists the raw images and their labels: 50,000 train / 10,000 test, 6,000 images per class the bug it hides labels misaligned with images — held-out loss parks on ln 10 = 2.3026 and never leaves number to remember 60,000 × 3,072 = 184,320,000 values ≈ 184 MB raw

The five invariants live across these stages: model.train() / model.eval(), zero_grad() before backward(), .item() on every accumulated metric, no_grad() at eval, and argmax over raw logits. Check them in this order and most failures name themselves.

Quick check

You delete optimizer.zero_grad() from the loop, so gradients accumulate across steps. What does the run look like?

READING THE LOSS CURVE

Every silent bug has a shape.
Learn the shapes.

You cannot afford a debugging run per hypothesis, but you get a loss curve for free on every run. Nine components, nine characteristic shapes — and four exact numbers that anchor the y-axis.

The y-axis of a well-behaved CIFAR-10 run starts at ln 10 = 2.3026 and falls toward a floor set by your label recipe. That gives four reference values worth memorizing, because they are exact and they appear in the curve:

chance, 10 classes ln 10 = 2.3026 one-hot targets floor 0 (the model can memorize) label smoothing, ε = 0.1 floor 0.5448 (0.9 on the truth, 0.0111 × 9) mixup, λ = 0.5 floor 0.6931 (ln 2 — two answers) mixup, λ = 0.7 floor 0.6109 double softmax (the bug) floor 1.4612 (≈ e/(e+9) at best) a train loss that sits on one of these floors is not stuck — it is doing exactly what the target tells it to do

A flat line at chance is a data bug. If held-out loss stays on 2.3026 from epoch 0 while train loss falls, the model is fitting something the test set does not contain: misaligned labels, shuffled targets, or a train/validation split from two different worlds. The lab’s shuffled-label run keeps held-out exactly on ln 10 while train crawls from 2.303 to 2.157 — the model is memorizing noise. No amount of training fixes this.

A spike is a rate bug. A loss that jumps from 2.30 to 9.4 by epoch 4 and then oscillates above chance is a learning rate above the stability limit: oversized steps destroy what was learnt and the survivors never settle. The pathological version is raw pixels with no normalization — the input scale multiplies every gradient by about 255, the logits overflow exp(), and the curve reads 2.30 → 12.4 → 41.0 → NaN.

A growing gap is a regularization bug. Train loss going to 0.03 while held-out loss bottoms out near 0.80 at epoch 12 and climbs back to 1.07 is the textbook overfitting curve: the model has moved from learning the signal to memorizing the 50,000 training images. Augmentation, weight decay, and early stopping are the correct responses — and so is auditing labels, because noisy labels produce the same shape at lower epochs.

A crawl is also a rate bug. The lr-too-low run falls in a straight, almost horizontal line: 2.303 → 2.063 over twenty epochs, a 0.24 drop for an afternoon of GPU time. The curve is not wrong — the steps are just too small, which is why the learning-rate range test from Phase 3 exists.

Predict the curve before you touch the code

Nine runs, one broken component each. The faint dashed line is the healthy reference — compare shapes, not just final numbers, because every one of these bugs raises no error.

broken component dataset / labels what you see (epoch 20 unless stated) held-out loss parked exactly on ln 10 = 2.3026 from epoch 0 likely diagnosis the labels and images are misaligned or shuffled — the model is being taught noise first thing to try print ten (image, label) pairs and look at them; audit the label column before retraining end of run train 2.157 held-out 2.303 gap 0.146 calibration constants chance (10 classes) ln 10 = 2.3026 label smoothing ε = 0.1 floor 0.5448 double softmax floor 1.4612

The loss curve is the only free diagnostic you get on every run. Match its shape to a row above before changing code: a flat line at 2.303 is a data problem, a spike is a learning-rate problem, and a growing train/held-out gap is a regularization problem.

Quick check

Held-out loss sits at exactly 2.3026 from epoch 0 while train loss slowly falls. What is the first thing to check?

AUGMENTATION THAT RESPECTS LABELS

Same label.
Different pixels.

A CNN inherits translation invariance from weight sharing and nothing else. Crops, flips, occlusion and lighting have to be taught — and the only way to teach them is to show the model pixels that exercise them. The contract is one sentence long: the label must survive the transform.

Every random transform is a statement: “these two images have the same label; learn the features that ignore the difference.” A flipped dog is still a dog; a dog with an 8×8 patch missing is still a dog; a crop of a dog is still a dog. That is why the source’s first two transforms are a 50% horizontal flip and a reflect-pad random crop. The rule breaks the moment the transform moves the label: 180° rotation turns a digit 6 into a 9, vertical flips turn “dog” poses into impossible anatomy, and a crop that is too large deletes the object. Augmentation helps only when the label survives.

The source’s crop uses mode="reflect" rather than zeros. A zero-padded crop carries a hard black frame, and the frame’s position leaks the crop offset — the network can learn to read the corners and partially undo the augmentation. Reflect padding mirrors real edge pixels, so every crop still looks like a photograph. It is a one-word change that shows up as roughly a point of test accuracy.

Mixup goes further: it interpolates both the image and the label. Sample λ ~ Beta(0.2, 0.2), mix two examples, and train against the mixed target. The model stops memorizing spiky one-hot answers and learns to behave smoothly between classes — which is the right prior for natural images and the reason the source calls it “the single cheapest robustness upgrade for any classifier”.

mixup + soft cross-entropy, from scratchpython
def mixup_batch(x, y, num_classes, alpha=0.2):
    if alpha <= 0:
        return x, torch.nn.functional.one_hot(y, num_classes).float()
    lam = float(np.random.beta(alpha, alpha))
    idx = torch.randperm(x.size(0), device=x.device)
    x_mixed = lam * x + (1 - lam) * x[idx]
    y_onehot = torch.nn.functional.one_hot(y, num_classes).float()
    y_mixed = lam * y_onehot + (1 - lam) * y_onehot[idx]
    return x_mixed, y_mixed

def soft_cross_entropy(logits, soft_targets):
    # cross-entropy against a distribution; reduces to the usual loss
    # when the target is exactly one-hot
    log_probs = torch.log_softmax(logits, dim=-1)
    return -(soft_targets * log_probs).sum(dim=-1).mean()
Adapted from python code/main.py. One α, one permutation, two blends — and the loss becomes a weighted log-probability instead of a single-class lookup.

Label smoothing is mixup’s cheaper cousin. Instead of a one-hot target, the true class gets 1 − ε and each of the other C − 1 classes gets ε/(C − 1). At ε = 0.1 over ten classes that is 0.9 and 0.0111 nine times. Two consequences follow, and both are measurable: the model can no longer drive logits to infinity, which improves calibration at essentially no accuracy cost, and the loss acquires a floor equal to the entropy of the target — 0.5448. If your training loss stops at 0.545 and refuses to move, nothing is broken: that is label smoothing doing its job. PyTorch has it built in since 1.10: nn.CrossEntropyLoss(label_smoothing=0.1).

Cutout is the third trick and the simplest: zero out a random square of the input. An 8×8 patch covers 64 of 1,024 pixels — 6.25% of a CIFAR image — which is small enough that the label survives and large enough that the model cannot rely on any single region. This is the same idea as random erasing, and it is four lines:

cutout and label smoothing, the short versionspython
def cutout(img, size=8):
    h, w = img.shape[:2]
    y0 = np.random.randint(0, h - size)
    x0 = np.random.randint(0, w - size)
    img = img.copy()
    img[y0:y0 + size, x0:x0 + size, :] = 0     # 64 / 1024 = 6.25% of pixels
    return img

loss = nn.functional.cross_entropy(logits, y, label_smoothing=0.1)
# targets: 0.9 for the true class, 0.1/9 = 0.0111 for each of the other nine
# the target's entropy 0.5448 is the lowest loss the model can ever report
Both are one-liners in a real pipeline. The design question is never 'does it help?' but 'does the label survive?' — which is what the chapter's lab lets you check by eye.
TrickAdd it when…Skip it when…Cost you can measure
Flip + cropalways — the first augmentation any vision pipeline getsthe label is mirror-sensitive (laterality, text, digits)one word: padding_mode="reflect"
Cutoutthe model overfits and objects are big enough to survive a 6.25% holeobjects are tiny, or train and val loss are already closea regularizer: it can hurt when there is no overfitting to fix
Mixupyou want the cheapest robustness and calibration upgrade and can train longerthe run is already underfitting — harder targets do not helptrain loss floor rises (0.6109 at λ = 0.7); expect slower early epochs
Label smoothingalmost always — ε = 0.1, one keyword argumentyou are debugging the loss itself and want a clean zero floorfloors the loss at 0.5448, which makes “loss below x” checks meaningless

One more number matters, because it explains mixup’s behaviour on short runs. β(0.2, 0.2) has mean 0.5 but is U-shaped: about ≈67% of draws land within 0.1 of 0 or 1 (barely mixed, almost the original example) and only about ≈6.5% land in the 0.4–0.6 middle (genuinely ambiguous blends). So most of a mixup batch is asked a question that has a precise answer, a small fraction is asked for an interpolation, and the epoch-average loss floor lands somewhere in the 0.35–0.5 range instead of 0. That is the entire mystery of “train loss went up and the model got better”.

The augmentation playground

Toggle the training-time transforms on a pair of toy images and watch what happens to the pixels — and to the target the loss is measured against. One rule survives every toggle: a pixel transform must leave the label alone.

pipeline · dataset: [32, 32, 3] uint8, label 3 (cat) · crop: pad 4 (reflect) then window at (0, 0) · flip: horizontal, p = 0.5 · normalize: (pixel − mean) / std, per channel · label smoothing: target = (1−ε)·target + (ε/(C−1))·(1−target) with ε = 0.10 target (classes above 0.02) cat 0.900 floor = 0.5448 one-hot 0.0000 (the model can memorize) smoothing ε = 0.1 0.5448 (0.9 / 0.0111 per class) mixup λ = 0.5 0.6931 (ln 2 — the model is told two answers)

Flip the pad mode and watch the corners: zero padding paints black bars the network can learn to detect, which is why the community default is reflect. Flip mixup on and the floor above rises — training loss should look worse, because the target now contains two answers.

Quick check

Your training loss falls from 2.30 and then flattens at exactly 0.545 while held-out accuracy keeps improving. What is the most likely explanation?

BEYOND ACCURACY

Accuracy is one number.
The matrix is a hundred.

A 10×10 confusion matrix counts what actually happened: entry (i, j) is the number of images of true class i that the model predicted as class j. The diagonal is right; everything else is a place to look.

Reading one class out of the matrix is a four-count exercise. For a given class, TP is the diagonal cell — true and predicted both that class; FP is everything else in its column — other classes that were predicted as it; FN is everything else in its row — its images predicted as something else. From those three counts:

precision = TP / (TP + FP) "of the images we flagged as cat, how many were cats?" recall = TP / (TP + FN) "of the actual cats, how many did we find?" F1 = 2·P·R / (P + R) the harmonic mean — punishes ignoring either one ResNet preset, class 3 = cat TP 870 (diagonal) · FP 90 (column) · FN 130 (row) precision = 870 / (870 + 90) = 0.906 recall = 870 / (870 + 130) = 0.870 F1 = 2(0.906)(0.870) / (0.906 + 0.870) = 0.888 for comparison, ship: TP 965 · FP 36 · FN 35 precision 0.964 · recall 0.965 — ten points of recall better than cat

That is the whole point of the chapter. The ResNet preset reports 93.45% accuracy — and cat recall is 0.870 while ship recall is 0.965. The aggregate number cannot tell you that, because it averages a class that is almost solved with one that is not. The source’s own warning is the same: aggregate accuracy hides imbalance, and per-class numbers are what surface underperforming categories.

Then look at where the misses go. The matrix’s off-diagonal mass is not spread evenly: on the ResNet run, cat ↔ dog costs 97 images in the two directions, deer ↔ horse costs 58, cat ↔ deer 50, automobile ↔ truck 45. On the simple-CNN preset the same pairs are worse — cat ↔ dog costs 260 and cat recall falls to 0.660 — but the ordering is identical. A concentration of errors on one off-diagonal pair is a localised problem with localised fixes: inspect those images. You will find genuinely ambiguous classes, mislabelled data, or a missing invariance, and the source’s advice is explicit — blanket changes like more data or a new optimizer rarely help when the failure is this specific.

the per-class report, from a single matrixpython
def per_class_report(cm):
    tp = cm.diag().float()               # correct per class
    fp = cm.sum(dim=0).float() - tp      # column sum minus the diagonal
    fn = cm.sum(dim=1).float() - tp      # row sum minus the diagonal
    prec = tp / (tp + fp).clamp_min(1)
    rec  = tp / (tp + fn).clamp_min(1)
    f1   = 2 * prec * rec / (prec + rec).clamp_min(1e-9)
    return prec, rec, f1

# rows are true classes, columns are predictions
for t, p in zip(y.cpu(), pred.cpu()):
    cm[t, p] += 1
From python code/main.py. The matrix is a running count during evaluation; every metric in this chapter is a ratio over its rows and columns, which is why the confusion matrix is the thing to log, not just the accuracy.

Where the errors actually live

Two runs on the same 10,000 test images: a simple CNN at ~80% and a ResNet at ~93%. Pick a true class, then compare its precision and recall — and look at which classes steal its predictions.

run ResNet · ≈93% · the source's ResNet band: 93–95% top-1 accuracy 0.9345 (9345 / 10,000) macro P/R/F1 0.935 / 0.934 / 0.934 true class 3 · cat TP 870 FP 90 FN 130 support 1000 · predicted as this class 960 precision = TP/(TP+FP) = 870/(870+90) = 0.906 recall = TP/(TP+FN) = 870/(870+130) = 0.870 F1 = 2PR/(P+R) = 0.888 this class's misses went to dog 55 deer 30 frog 19 horse 15 worst pairs (both directions) cat ↔ dog 97 (55 + 42) deer ↔ horse 58 (30 + 28) cat ↔ deer 50 (30 + 20) automobile ↔ truck 45 (25 + 20)

The ResNet still confuses cat with dog 97 times in both directions — more than any other pair. A single accuracy number would never tell you that; the matrix names the next dataset problem to fix.

THE STANDARD RECIPE

Every knob has a default.
Use the defaults first.

The pipeline is plumbing, and plumbing has a recipe. These are the settings that get a ResNet into the 93–95% band on CIFAR-10 — and the wrong setting on any row is worth several accuracy points.

The source trains with an SGD optimizer, momentum 0.9, Nesterov, weight decay 5e-4, starting learning rate 0.1, and a cosine schedule that closes over T_max = 10 epochs. The learning rate is stepped by the scheduler once per epoch, after the optimizer step. That is the whole tuning stack — everything else in the pipeline is there to make those numbers behave.

KnobDefaultWhy this value
Batch size128391 batches per epoch over 50,000 images; the gradient noise of a 128-batch is itself a regularizer
OptimizerSGD momentum 0.9, nesterov, weight decay 5e-4the source's recipe; AdamW with a cosine schedule is the common modern substitute
Learning rate0.1 at the start, cosine to ~0SGD tolerates larger rates than Adam-style optimizers; the schedule closes the run
ScheduleCosineAnnealingLR(optimizer, T_max = epochs), stepped once per epochbig steps early, tiny steps late — the model settles instead of bouncing
NormalizationCIFAR-10 mean/std, computed on the training splitdataset-specific: ImageNet stats here are a ~0.19-value error on every pixel
Augmentationreflect-pad random crop + hflip (+ cutout, + mixup)training only, label-preserving; the eval transform is normalize-only
Losscross-entropy on raw logits, label_smoothing = 0.1the fused log-softmax is the stable form; smoothing sets a 0.5448 floor
Evaluationmodel.eval() · no_grad · no augmentation · argmax on logitstest-time augmentation is a different experiment — not the default pipeline
the same pipeline in four lines (torchvision)python
from torchvision.datasets import CIFAR10
from torchvision.transforms import Compose, RandomCrop, RandomHorizontalFlip, ToTensor, Normalize

mean = (0.4914, 0.4822, 0.4465)          # CIFAR-10's, not ImageNet's
std  = (0.2470, 0.2435, 0.2616)
train_tf = Compose([
    RandomCrop(32, padding=4, padding_mode="reflect"),
    RandomHorizontalFlip(),
    ToTensor(),
    Normalize(mean, std),
])
eval_tf = Compose([ToTensor(), Normalize(mean, std)])   # no augmentation

train_ds = CIFAR10(root="./data", train=True,  download=True, transform=train_tf)
val_ds   = CIFAR10(root="./data", train=False, download=True, transform=eval_tf)
Adapted from the source's Use It section. This is the whole hand-written pipeline collapsed into Compose — same order, same stats, same eval transform. Copy-pasting ImageNet stats into those two tuples is the ~1% accuracy leak nobody catches until someone profiles the model.
the recipe, end to endpython
device = "cuda" if torch.cuda.is_available() else "cpu"
model = TinyResNet(num_classes=10).to(device)
optimizer = SGD(model.parameters(), lr=0.1, momentum=0.9,
                weight_decay=5e-4, nesterov=True)
scheduler = CosineAnnealingLR(optimizer, T_max=10)

for epoch in range(10):
    tr_loss, tr_acc = train_one_epoch(model, train_loader, optimizer,
                                      device, 10, use_mixup=True)
    va_loss, va_acc, cm = evaluate(model, val_loader, device, 10)
    scheduler.step()                    # once per epoch, after the update
    print(f"epoch {epoch:2d}  lr {scheduler.get_last_lr()[0]:.4f}  "
          f"train {tr_loss:.3f}/{tr_acc:.3f}  val {va_loss:.3f}/{va_acc:.3f}")
Adapted from the source's main(). Five lines of tuning, ten epochs, and the per-class report from the previous chapter at the end — on the synthetic dataset this reaches near-perfect validation accuracy; on real CIFAR-10 the same loop trains to ~90%+ without changes.
Worked check — what the cosine schedule actually does

The schedule is lr(t) = 0.1 · (1 + cos(π·t/T)) / 2. Plug in real epochs with T = 10:

epoch 0 lr = 0.1 · (1 + cos 0) / 2 = 0.1000 epoch 1 lr = 0.1 · (1 + cos 0.1π) / 2 = 0.0976 epoch 2 lr = 0.1 · (1 + cos 0.2π) / 2 = 0.0905 epoch 5 lr = 0.1 · (1 + cos 0.5π) / 2 = 0.0500 ← halfway epoch 10 lr = 0.1 · (1 + cos π) / 2 = 0.0000

Two silent bugs live in that table. Stepping the scheduler per batch instead of per epoch finishes the entire cosine inside the first 10 batches of epoch 1 — 0.2% of the run gets the schedule, and the remaining 99.8% trains at lr ≈ 0, which the loss curve reports as a very early plateau. Never stepping it at all means the run oscillates at 0.1 forever and leaves accuracy on the table late in training. Both bugs produce a plausible curve, and both are a single line to fix.

The normalization row has the same character. With CIFAR’s own stats, pixel 255 maps to (1.0 − 0.4914)/0.2470 = 2.059; with ImageNet stats it maps to 2.249. That 0.190 error rides on every pixel of every image and every gradient that flows back, and the source’s estimate is roughly a point of accuracy that nobody catches until someone profiles the model.

TOP-5, CALIBRATION, SHIPPING

What accuracy never told you.
And what to do about it.

Accuracy answers one question badly: how often is the top guess right? Two more numbers decide whether a classifier is safe to ship — whether the right answer is near the top, and whether the model’s confidence means anything.

Top-1 vs top-5. Top-1 accuracy is the fraction of images whose true class is the single highest-probability prediction; top-5 is the fraction whose true class is anywhere in the five highest. Chance levels are exact and easy to remember: k/C, so on CIFAR-10 chance is 10% top-1 and 50% top-5. Top-5 exists because image classes are genuinely ambiguous at the edges — ImageNet’s Norwich terrier and Norfolk terrier differ by ear shape — so top-5 separates “the model has never seen anything like this” from “the model is deciding between two nearly identical breeds”. A 93.45% top-1 model with top-5 near 100% makes near-misses; the same top-1 with a weak top-5 is making different kinds of errors. The ResNet preset has 655 errors (10,000 − 9,345 of them); top-5 asks how many kept the true class in ranks 2–5. Aggregate accuracy is silent on the difference.

Calibration. Accuracy asks whether the prediction is right; calibration asks whether the confidence is honest. Expected Calibration Error (ECE) bins predictions by confidence and compares the bin’s average confidence to the bin’s observed accuracy; the weighted average of those gaps is one number. Take three bins from a miscalibrated model:

bin avg confidence samples observed accuracy 0.6 0.60 100 0.55 gap 0.05 0.8 0.80 200 0.74 gap 0.06 0.95 0.95 300 0.92 gap 0.03 ECE = (100/600)(0.05) + (200/600)(0.06) + (300/600)(0.03) = 0.0083 + 0.0200 + 0.0150 = 0.0433 read it as: "the model is, on average, 4.3 points more confident than it is right" every modern network has some version of this; Guo et al. (2017) fixed a lot of it with temperature scaling — one scalar T dividing the logits, fitted on validation

The fix is almost free. Divide the logits by a single scalar T > 1 before the softmax, tune it on a validation set, and the confidences line up with the accuracies without changing a single prediction — the ranking is untouched. Label smoothing does something similar during training: it stops the model from driving logits to infinity, which is exactly what makes the confidences dishonest in the first place.

The metric calculator

Type your own 3×3 confusion matrix — rows are true classes, columns are predictions — and read accuracy, per-class precision, recall and F1. Load the majority-trap preset to watch accuracy say 0.900 while two classes score zero.

Accuracy
0.8500
(TP+TN…)/n
Majority baseline
0.3333
max row / n
Macro F1
0.8495
mean of per-class F1
n
300
supports 100 / 100 / 100
90 / 80 / 85 on the diagonal, 300 labels total accuracy 0.8500 majority baseline 0.3333 macro F1 0.8495 per class class 0 TP 90 FP 15 FN 10 precision 90/(90+15) = 0.857 recall 90/(90+10) = 0.900 F1 0.878 class 1 TP 80 FP 14 FN 20 precision 80/(80+14) = 0.851 recall 80/(80+20) = 0.800 F1 0.825 class 2 TP 85 FP 16 FN 15 precision 85/(85+16) = 0.842 recall 85/(85+15) = 0.850 F1 0.846

Watch the majority-trap preset: accuracy 0.900 and the baseline are the same number, because predicting class 0 for every row is the baseline. Per-class recall collapses to 0.000 for both minority classes — that is the failure accuracy cannot see.

This is the last measurement you run before shipping, and it is worth running on your own numbers. Type a matrix you have seen — the clean 3-class one, the majority trap, or the CIFAR cat/dog/deer block — and read the per-class precision, recall and F1 next to the accuracy. The lesson’s deliverables follow directly: a pipeline auditor prompt that checks a training script against the five invariants and reports the first violation, and a classification diagnostics skill that takes a confusion matrix plus class names and proposes the single most impactful fix. Both are exercises in reading the numbers you just learned to read.

CHECK YOURSELF

Five questions.
Then the terms worth keeping.

Answer before you look. The softmax-before-cross-entropy question and the confusion-matrix question are the two that separate a pipeline you have memorized from one you can debug at 2 a.m.

0 / 5 answered · 0 correct

01Your model outputs raw logits of shape (N, C). You write `loss = cross_entropy(softmax(logits), y)`. What goes wrong?

02You evaluate a 10-class classifier and get 92% accuracy. Class 0 has 9,000 examples; classes 1–9 share 1,000 (about 111 each). What does the headline number hide?

03Mixup replaces one-hot labels with interpolated soft targets like λ·y_i + (1−λ)·y_j. Why does this help generalisation?

04You swap `RandomCrop(32, padding=4, padding_mode='zeros')` for `padding_mode='reflect'` on CIFAR-10. Why is reflect better here?

05You train a classifier and the confusion matrix shows most errors are class 3 predicted as class 5 and class 5 predicted as class 3. What is the single most impactful next step?

Key terms, demystified

Click a card to swap the lazy description for what it actually means — every definition carries the number that makes it checkable.

Exercises from the lesson

Three problems with exact numbers: run the mixup ablation and explain a higher training floor, implement cutout and compare four augmentation policies, and build the CIFAR-100 pipeline with a learning-rate sweep and a top-confusions table. Try first; a worked answer is one click away.

  1. Train the same model with and without mixup for five epochs on the synthetic dataset. Plot train and val loss for both. Explain why train loss with mixup is higher yet val accuracy is similar or better.
    Show one worked answer

    Run both arms with the same seed, the same optimizer and the same schedule; change only `use_mixup`. With mixup on, the target is a mixture and its entropy is the loss floor: at λ = 0.7 the floor is −(0.7 ln 0.7 + 0.3 ln 0.3) = 0.6109, at λ = 0.5 it is ln 2 = 0.6931, and a draw near λ = 0.9 has a floor of only 0.3251. Because λ ~ Beta(0.2, 0.2) is U-shaped, most batches are barely mixed and only ≈6.5% land in the 0.4–0.6 middle, so the epoch-average floor typically sits in the 0.35–0.5 range — visibly above the one-hot run's 0. That is the whole explanation: the mixup run is not training worse, it is being asked an unanswerable question for part of each batch ("this image is 70% cat and 30% dog") and answering it smoothly. Expected result on the synthetic set: train loss ~0.4–0.7 higher, held-out loss a little lower at epoch 5, accuracy within a point; on real CIFAR-10 with a small CNN, expect roughly +1 point of test accuracy and noticeably better calibration. Plot both train curves and both val curves on the same log-scale axes; the shape to look for is a higher train floor with a smaller train/val gap.

  2. Implement Cutout — zero out a random 8×8 square in each training image — and run an ablation: no augmentation, hflip+crop, hflip+crop+cutout, hflip+crop+mixup. Report val accuracy for each.
    Show one worked answer

    The implementation is three lines: pick x0, y0 uniformly in [0, 24), then `img[y0:y0+8, x0:x0+8] = 0` before normalizing. An 8×8 square covers 64 of 1,024 pixels — 6.25% of the image — which is the sweet spot the original paper found for CIFAR-style data; larger patches start deleting the object and the label stops surviving the transform. Keep the random seed fixed across arms and report test accuracy, not train. Expected ordering on CIFAR-10 with a small CNN: none (baseline, the 80.5%-preset ballpark) < hflip+crop (+1 to 2 points) < hflip+crop+cutout (another +0.5 to 1) ≈ hflip+crop+mixup (mixup is stronger on longer schedules and often looks worse at epoch 5 because its loss floor is high). Two honest caveats: cutout is a regularizer, so it helps most when the model overfits — if train and val are already close, it can hurt; and it interacts with mixup, so do not stack every trick at once. The deliverable is the four-row table plus one line of interpretation per row: augmentation helps only when the label survives.

  3. Build a CIFAR-100 pipeline (100 classes, same input size) and reproduce a ResNet-34 training run to within 1% of published accuracy. Extras: sweep three learning rates and two weight decays, log to a local CSV, and produce the final top-confusions table.
    Show one worked answer

    Everything in the lesson transfers unchanged except the numbers. Chance loss becomes ln 100 = 4.6052 (versus 2.3026). The dataset is 50,000 train / 10,000 test with 500 train and 100 test images per class, so the same batch 128 gives 391 batches per epoch and the test pass is 79 batches. Read the ResNet-34 paper's CIFAR-100 top-1 number for your target and hold yourself to within 1 point. The sweep is the lesson's real content: baseline lr 0.1, cosine to 0, weight decay 5e-4, batch 128 — then lr ×3 and lr ÷3, and weight decay 5e-4 vs 5e-3, one variable per run. Log epoch, wall-clock seconds, learning rate, train loss, val loss and val accuracy to CSV, then plot loss against step (not epoch) for the six runs on one axis. Diagnostics to report: whether the best run was still improving when the cosine closed (if yes, train longer; if val loss bottomed early, regularize), and whether the top-confusions table shows the CIFAR-100 superclass structure (people, animals, vehicles) clustering errors — it usually does, which is the 100-class version of cat ↔ dog. If your best run misses the published number by more than 1 point, the culprit is a pipeline detail, not the architecture: normalization stats, the reflect-pad crop, or the schedule.

Terms this lesson borrows from later lessons (or outside)

You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.

  • model evaluationPrecision, recall, F1, confusion matrices, calibration and the accuracy paradox — the entire evaluation vocabulary this lesson uses to read a 10-class matrix. Built up in Phase 2, Lesson 09 with the four-count arithmetic.
  • mini frameworkThe from-scratch training loop — forward, loss, zero_grad, backward, step — that this lesson's five invariants are written against. The loop the source wires by hand before touching any framework. (Phase 3, Lesson 10)
  • PyTorchThe tooling behind the source's pipeline: Dataset/DataLoader, nn.CrossEntropyLoss with its fused log-softmax, one_hot for soft targets, and CosineAnnealingLR. (Phase 3, Lesson 11)
  • CNNs — LeNet to ResNetThe model the lesson trains: a small VGG-style classifier for the from-scratch run and a TinyResNet for the 93% band. BatchNorm and residual connections are why the ResNet band exists at all. (Phase 4, Lesson 03)
  • learning-rate schedulesCosine decay driven by the scheduler: one `scheduler.step()` per epoch after the optimizer step, so the effective learning rate starts at 0.1 and closes on 0. Stepping it per batch by mistake is one of the classic silent bugs. (Phase 3, Lesson 09)
KEEP GOING

A picture is a start.
Practice is the rest.

This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.

Lesson text adapted from AI Engineering from Scratch (Phase 04, Lesson 04) and the Math Foundations Notebook reference build. The five labs (the loss-curve diagnostic board, the augmentation playground, the 10×10 confusion explorer, the metric calculator and the pipeline stepper) are original to this page, as are the pipeline arithmetic (184,320,000 values, 391 and 79 batches, 1.5 MiB per batch), the normalization hand-checks and the ImageNet-stats gap, the label-smoothing floor 0.5448, the mixup floors with the Beta(0.2, 0.2) U-shape, the double-softmax floor 1.4612, the no-shuffle sawtooth and raw-pixel NaN paths, the two 10×10 CIFAR-10 confusion matrices with their per-class metrics and worst pairs, the top-k chance arithmetic, and the three-bin ECE example. The stylized teaching curves in chapter 03 are labelled as such; every other number is computed live by the labs or verified by hand in the prose.