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%.
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 test02 / 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.
01
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.
Component
Classic bug
What you see
First check
Dataset / labels
images and labels out of sync
held-out loss parked on ln 10 = 2.3026 from epoch 0
print ten (image, label) pairs and look at them
Normalization
raw 0–255 pixels, or ImageNet stats on CIFAR
loss jumps to 12 → 41, then NaN by epoch 3
recompute mean/std on the training split
DataLoader
shuffle=False in training
epoch-level sawtooth; converges late and noisily
shuffle=True for train, False for test
Model
output size ≠ classes; train()/eval() swapped
accuracy near chance, or dropout at test time
print the output shape; assert the mode
Loss
softmax applied before cross-entropy
loss stalls near 1.46 and never reaches the healthy band
pass raw logits to the loss
Optimizer
lr above the stability limit
spike to 9.4 by epoch 4, then permanent oscillation
lower lr 10×; check gradient norms
Scheduler
stepped per batch, or never stepped
never settles, or freezes after a few epochs
one scheduler.step() per epoch
Evaluation
augmentation left on at test time
held-out accuracy several points below its potential
eval transform = normalize only, model.eval()
Regularization
no augmentation, no weight decay
train → 0.03 while held-out bottoms at 0.80 and climbs
augmentation, 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?
02
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.0for 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?
03
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?
04
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 pixelsreturn 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.
Trick
Add it when…
Skip it when…
Cost you can measure
Flip + crop
always — the first augmentation any vision pipeline gets
the label is mirror-sensitive (laterality, text, digits)
one word: padding_mode="reflect"
Cutout
the model overfits and objects are big enough to survive a 6.25% hole
objects are tiny, or train and val loss are already close
a regularizer: it can hurt when there is no overfitting to fix
Mixup
you want the cheapest robustness and calibration upgrade and can train longer
the run is already underfitting — harder targets do not help
train loss floor rises (0.6109 at λ = 0.7); expect slower early epochs
Label smoothing
almost always — ε = 0.1, one keyword argument
you are debugging the loss itself and want a clean zero floor
floors 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?
05
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 predictionsfor 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.
06
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.
Knob
Default
Why this value
Batch size
128
391 batches per epoch over 50,000 images; the gradient noise of a 128-batch is itself a regularizer
Optimizer
SGD momentum 0.9, nesterov, weight decay 5e-4
the source's recipe; AdamW with a cosine schedule is the common modern substitute
Learning rate
0.1 at the start, cosine to ~0
SGD tolerates larger rates than Adam-style optimizers; the schedule closes the run
Schedule
CosineAnnealingLR(optimizer, T_max = epochs), stepped once per epoch
big steps early, tiny steps late — the model settles instead of bouncing
Normalization
CIFAR-10 mean/std, computed on the training split
dataset-specific: ImageNet stats here are a ~0.19-value error on every pixel
Augmentation
reflect-pad random crop + hflip (+ cutout, + mixup)
training only, label-preserving; the eval transform is normalize-only
Loss
cross-entropy on raw logits, label_smoothing = 0.1
the fused log-softmax is the stable form; smoothing sets a 0.5448 floor
Evaluation
model.eval() · no_grad · no augmentation · argmax on logits
test-time augmentation is a different experiment — not the default pipeline
the same pipeline in four lines (torchvision)python
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.
07
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.
08
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.
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.
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.
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 evaluation — Precision, 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 framework — The 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)
PyTorch — The 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 ResNet — The 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 schedules — Cosine 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.