Session S12

Training in Practice: Debugging, Diagnosis, Experiment Tracking

A fixed sequence of cheap experiments that bugs cannot hide from, and the bookkeeping that makes a result trustworthy.

Deep Learning · CS 4715/5715 · University of Idaho

📋 Admin: HW2 out today; Quiz 2 next session

  • HW2 (CNN + transfer learning) is out today and due at S18, Wednesday of the week after next: convolution and max pooling by hand in NumPy, then a frozen-trunk transfer experiment.
  • Quiz 2 is next session (S13), in the final 30 minutes, in Canvas. It covers Module 2: S09–S12.
  • Project pre-proposal (≤1 page: question, data, baseline, metric) due at the end of this week (the S13 week); every member submits the same PDF. Today's second half is how to compare against a baseline fairly, fix the metric in advance and estimate uncertainty, which the proposal (end of the S23 week, four weeks after the pre-proposal) adds.

Apply today's recipe from the first day of HW2.

Recap from S11: transfer in miniature

Task A (source)
2,000 images,
4 orientations

class 0

class 1

class 2

class 3

Task B (target)
24 labeled images

class 0: "+"

class 1: "x"

Pretrain the trunk on task A
→
Keep the trunk; attach a new 2-class head
→
Train only the head, on the 24 task-B images
  • S11's test accuracy on 500 task-B images: transfer 0.8287 (three runs, seeds 0–2: 0.764, 0.774, 0.948), against 0.6707 for the same network trained from scratch (0.594, 0.538, 0.880).
  • S11's rule: "Default: freeze first, and unfreeze once the data can support it."

A project's backbone is real: today, how much of it to train, and how transfer fails without an error.

Probe or fine-tune, measured: three ways to adapt the same trunk

How much of the trunk should train? Three answers on S11's miniature, at 24 and 200 target examples (reading 2.3): each strategy starts from the same pretrained trunk with a fresh head (model is the two in sequence).


# cond: one of "probe (frozen trunk)", "fine-tune (trunk lr/10)", "fine-tune (one big lr)"
if cond.startswith("probe"):                 # linear probe: trunk frozen
    for p in trunk.parameters():
        p.requires_grad = False
    groups = [{"params": head.parameters(), "lr": 1e-3}]
elif "lr/10" in cond:                        # discriminative learning rates
    groups = [{"params": head.parameters(), "lr": 1e-3},
              {"params": trunk.parameters(), "lr": 1e-4}]
else:                                        # one aggressive rate for everything
    groups = [{"params": model.parameters(), "lr": 1e-2}]
train(model, Xb, yb, epochs=150, param_groups=groups)   # this train takes param_groups
  
  • This example's train takes param_groups in place of params and builds torch.optim.Adam(param_groups): a list of dictionaries, each naming some parameters and their learning rate.
  • Discriminative learning rates: the head at the normal rate, the pretrained trunk at one tenth of it.

At 24 examples the strategies tie; at 200, lr/10 beats the probe in each seed


--- 24 target examples ---
probe (frozen trunk)     mean = 0.8287 (seeds: 0.764, 0.774, 0.948)
fine-tune (trunk lr/10)  mean = 0.8367 (seeds: 0.770, 0.798, 0.942)
fine-tune (one big lr)   mean = 0.8253 (seeds: 0.808, 0.724, 0.944)
--- 200 target examples ---
probe (frozen trunk)     mean = 0.9907 (seeds: 0.990, 0.988, 0.994)
fine-tune (trunk lr/10)  mean = 0.9953 (seeds: 0.994, 0.996, 0.996)
fine-tune (one big lr)   mean = 0.9947 (seeds: 0.990, 0.994, 1.000)
  
  • 24 examples: test accuracy 0.825–0.837, about a point apart, against seed spreads of 17 to 22 points: a tie. S11's rule, measured: freeze first; the probe is the cheapest option.
  • 200 examples: errors fall from 14 (probe) to 7 (trunk at lr/10) of 1,500 test images: 0.93% to 0.47%, fewer in each of the three seeds, so the trunk at lr/10 pays.
  • One big rate cost nothing here: this trunk is small and the task easy, so even scrambled features are relearned from 200 examples. A 25-million-parameter backbone's features are not (reading 2.3).

On a real backbone, transfer fails without an error

symptomcausecheck
fine-tuned accuracy falls below the frozen baselinea learning rate meant for a random initialization destroys the pretrained featurestrain the trunk at \(1/10\) to \(1/100\) of the rate, or freeze it first
a frozen trunk scores near chancethe head reads the wrong tensor, or the preprocessing differs from the source model'scheck the feature size entering the head; copy the input size and normalization constants
a frozen trunk's features for a fixed image change from epoch to epochBatchNorm's running statistics still updatethe next two pages

The transfer recipe for a project (reading 2.3): match the preprocessing, start with a linear probe, add augmentations checked against your labels, then, once the data can support it, unfreeze the last block first at a smaller trunk rate.

Each shows up as a worse number, not an error.

Freezing parameters does not freeze BatchNorm's statistics

Row 3 of the previous page, shown. A real backbone such as S11's ResNet-18 has a BN layer (S07) after every convolution; HW2's model has no BN. After import torch, import torch.nn as nn: nn.BatchNorm2d(8) normalizes each of 8 channels over \((N,H,W)\).


torch.manual_seed(0)
trunk = nn.Sequential(nn.Conv2d(1, 8, 3, padding=1), nn.BatchNorm2d(8), nn.ReLU())
for p in trunk.parameters():
    p.requires_grad = False            # conv weights, BN's gamma and beta
bn = trunk[1]                          # one mean and variance per channel
print([name for name, _ in bn.named_buffers()])
x = torch.randn(32, 1, 16, 16) + 2.0   # a batch from a shifted domain
before = bn.running_mean.clone()       # a copy: BN updates running_mean in place
trunk(x)                               # a module starts in training mode
print(f"max change {(bn.running_mean - before).abs().max().item():.4f}")
  

['running_mean', 'running_var', 'num_batches_tracked']
max change 0.2110
  

named_buffers() prints BN's buffers: averages it updates on every forward pass, with no gradient, so requires_grad cannot stop them. In training mode BN moves its running statistics toward each batch's; in evaluation mode it uses them.

An outer model.train() switches a frozen trunk back

The previous page's natural fix is trunk.eval(). But a training loop calls model.train() at the start of every epoch, and train() and eval() reach every submodule. trunk, bn and x come from the previous page.


model = nn.Sequential(trunk, nn.Flatten(), nn.Linear(8 * 16 * 16, 2))
trunk.eval()                       # freeze BN's statistics, once, before training
model.train()                      # the first line of every epoch
before = bn.running_mean.clone()
model(x)                           # a training batch
print(f"trunk.training={trunk.training}, max change {(bn.running_mean - before).abs().max().item():.4f}")
  

trunk.training=True, max change 0.1899
  

model.train()                      # the next epoch starts
trunk.eval()                       # the fix: re-apply eval() after every model.train()
before = bn.running_mean.clone()
model(x)
print(f"trunk.training={trunk.training}, max change {(bn.running_mean - before).abs().max().item():.4f}")
  

trunk.training=False, max change 0.0000
  

Re-apply eval() to the frozen trunk after every model.train() (or override train()), and check running_mean before and after an epoch.

Neural networks fail silently

These transfer failures give a worse number, never an error. So do most training bugs. How do you know that a training loop works?

Most software fails loudly

A wrong index raises an IndexError; a bad type raises a TypeError. The stack trace names the line.

A network with a subtle bug usually still trains

Misaligned labels, a transform applied twice, the wrong tensor fed to the loss: the loss falls, accuracy rises, nothing crashes.

  • No stack trace exists for these bugs. The model ends up quietly worse than it should be.
  • What works is a fixed sequence of cheap experiments, each built so that one class of bug cannot hide from it (Karpathy, "A Recipe for Training Neural Networks", 2019).

A pipeline bug shows up as a worse number, not as an error.

The recipe: six steps, in order

Each step is cheap and assumes that the steps before it have passed (reading 2.4).

1Look at the data as the model sees it, after each preprocessing step
2Dumb baselines: the most common class; a linear model on pixels
3One end-to-end run at toy scale
4Overfit one batch
5Scale up until it overfits, then regularize
6Tune coarse to fine, learning rate first
Change one thing at a time.

An improvement after three changes at once tells you nothing you can build on.

Keep the last working configuration.

A committed config, a saved seed, a checkpoint: a state you can return to.

While debugging, fix the seed, so that two runs differ only in what you changed.

The expensive mistakes come from skipping ahead. HW2 starts at step 1.

Overfit one batch: the logic, then a prediction

Step 4. Reading 2.4 runs it on a small CNN twice: once correct, once with a common bug.

  • Train on 8 examples with random labels and nothing else. There is nothing to generalize, and a healthy network has far more capacity than 8 examples need: it must memorize them and drive the loss to about 0.
  • If it cannot, something between the input tensor and the optimizer step is broken, and a test that runs in seconds has localized the bug to that pipeline.

4 classes, and predictions roughly uniform at step 0: what loss should the first step print?

Uniform predictions give the true class \(p=\tfrac14\), so the loss is \(-\ln\tfrac14=\ln4\approx1.386\).

A large departure is a prompt to check class balance, bias initialization, output scale and loss wiring, not proof of a bug. With a pretrained trunk, a fresh head's step-0 loss can sit away from \(\ln K\) (here \(\ln 4\)) for the same reason.

The test in code: a small CNN, 8 random images, 8 random labels

Reading 2.4's example, first half. The layers are HW2's SmallCNN(4); make_model(buggy=True) appends one more.


import torch
import torch.nn as nn
torch.manual_seed(0)
torch.set_num_threads(1)   # single-threaded: exact run-to-run reproducibility
def make_model(buggy=False):
    layers = [
        nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
        nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
        nn.Flatten(), nn.Linear(16 * 4 * 4, 4),        # logits: (8, 4)
    ]
    if buggy:
        layers.append(nn.Softmax(dim=1))   # BUG: CrossEntropyLoss wants logits
    return nn.Sequential(*layers)
x = torch.randn(8, 1, 16, 16)      # 8 random images
y = torch.randint(0, 4, (8,))      # 8 random labels, 4 classes
  

nn.CrossEntropyLoss applies log_softmax to whatever it receives. The bug layer applies a softmax first, so the loss receives probabilities in \([0,1]\) where it expects logits.

One loop trains both models; only buggy differs

Second half: make_model, x and y from the previous page; 501 Adam steps on the one batch. argmax(1) picks each row's largest output, so acc is the share of the 8 labels predicted correctly.


for buggy in [False, True]:
    torch.manual_seed(0)                 # the same starting weights for both
    model = make_model(buggy)
    opt = torch.optim.Adam(model.parameters(), lr=1e-3)
    loss_fn = nn.CrossEntropyLoss()
    tag = "buggy (softmax before loss)" if buggy else "correct model"
    print(f"--- {tag} ---")
    for step in range(501):
        opt.zero_grad()
        loss = loss_fn(model(x), y)
        loss.backward()
        opt.step()
        if step % 100 == 0:
            acc = (model(x).argmax(1) == y).float().mean().item()
            print(f"step {step:3d}: loss = {loss.item():.4f}, batch acc = {acc:.2f}")
  

Softmax keeps the order of the logits, so the bug layer leaves the argmax, and the accuracy, unchanged. Predict the buggy run: will its loss also reach about 0?

The buggy model reaches 100% accuracy and still fails the test

The printout of the previous page's loop (reading 2.4):


--- correct model ---
step   0: loss = 1.3411, batch acc = 0.75
step 100: loss = 0.0148, batch acc = 1.00
step 200: loss = 0.0024, batch acc = 1.00
step 300: loss = 0.0009, batch acc = 1.00
step 400: loss = 0.0004, batch acc = 1.00
step 500: loss = 0.0002, batch acc = 1.00
--- buggy (softmax before loss) ---
step   0: loss = 1.3746, batch acc = 0.75
step 100: loss = 0.7499, batch acc = 1.00
step 200: loss = 0.7448, batch acc = 1.00
step 300: loss = 0.7441, batch acc = 1.00
step 400: loss = 0.7439, batch acc = 1.00
step 500: loss = 0.7438, batch acc = 1.00
    
  • Both start near \(\ln4\approx1.386\): 1.3411 and 1.3746.
  • Correct: the loss reaches 0.0002. It passes.
  • Buggy: batch accuracy 1.00 by step 100, but the loss stalls, 0.7499 at step 100 and 0.7438 at step 500. It fails.
  • Each line's loss is computed before that step's update, its accuracy after it; at step 0 the outputs are nearly uniform, so small differences decide the argmax: 0.75.

The criterion is a loss near 0, not accuracy: accuracy only checks the argmax.

Why the buggy loss cannot fall below 0.7437

Derivation. Prerequisites (S03): softmax, and the loss \(-\ln p_y\) (S03's \(-\log p_y\), natural log) for the true class \(y\).

  1. The buggy model outputs \(q=\operatorname{softmax}(z)\): 4 numbers in \([0,1]\).
  2. CrossEntropyLoss treats \(q\) as logits and applies softmax again: \(p_y=e^{q_y}/\sum_k e^{q_k}=1/\big(1+\sum_{k\ne y}e^{q_k-q_y}\big)\).
  3. Every \(q_k-q_y\ge 0-1=-1\), so each of the 3 terms is at least \(e^{-1}\): \(p_y\le 1/(1+3e^{-1})=0.4754\).
  4. \[L=-\ln p_y\ge\ln\left(1+3e^{-1}\right)=0.7437,\] with equality only at one-hot \(q\). With \(y\) the first class: \((1,0,0,0)\) gives 0.7437, \((0.7,0.1,0.1,0.1)\) gives 0.9732, \((0.25,0.25,0.25,0.25)\) gives 1.3863 \(=\ln4\).

The run sits at 0.7438: stuck at the floor, not heading to 0. On real data nothing flags this bug: no error, and accuracy may barely move; on one batch it is a loss stuck at 0.74.

Self-check: the floor with \(K\) classes

The same steps with \(K\) classes: \(K-1\) terms, each at least \(e^{-1}\).

Where is the floor with 2 classes, and with 10? How does each compare with \(\ln K\), the step-0 loss of uniform predictions?

\[L_{\min}(K)=\ln\left(1+(K-1)\,e^{-1}\right)\]

\(L_{\min}(2)=0.3133\), against \(\ln 2=0.6931\). \(L_{\min}(10)=1.4612\), against \(\ln 10=2.3026\). \(L_{\min}(4)=0.7437\) again.

Against \(\ln K\), the starting loss: the buggy run falls part of the way, then stops far above 0.

With the bug, even a model that memorizes the batch stays well above 0, at every \(K\).

What overfitting one batch catches, and what it misses

bugwhat the loss doesthe test
the optimizer is built without the model's parametersno weight changes; the loss stays at its step-0 valuecaught
labels re-shuffled at every stepa new target each step; the loss stays far above 0caught
only the trunk frozenthe head alone memorizes the 8 examples; the loss still reaches about 0missed
images and labels misaligned once (shifted by one, the same way every step)with random labels this is just another random labeling; the loss reaches about 0missed; step 1, looking at the data, catches it
opt.zero_grad() missinggradients pile up; the loss swings up, then still ends near 0missed

Passing shows the loop can memorize; it does not show the pipeline is right.

Reading the curves: four patterns

Step 5 scales up; from then on, read training and validation loss against the step.

underfitting loss step both high and close overfitting loss step early stop validation turns upward stable gap: normal loss step both flat, gap constant training-only shortcut loss step training ≈ 0, validation high

Schematic, not measured. Solid blue: training loss; dashed orange: validation loss. Early stopping keeps the checkpoint at the validation minimum. Training-only shortcut: training loss near 0 at once, validation high.

The sign of overfitting is validation loss rising while training loss falls, not any gap.

Each pattern calls for a different change

The previous page's four patterns, with what each means and what to change (reading 2.4).

patternwhat it meanswhat to change
Underfittingthe model has not extracted what the training data offerstrain longer, raise capacity, raise the learning rate, or reduce regularization
Overfittingthe model memorizes details of the training setmore data or augmentation, more regularization, or a smaller model; early stopping keeps the validation minimum
Stable gapthe generalization gap, settlednothing: a stable gap is normal
Training-only shortcuta shortcut that exists only in the training data, such as a feature the training pipeline builds from the labelsfind the shortcut before trusting any number
  • Validation loss can rise while validation accuracy improves: loss also punishes confident mistakes. Decide in advance which one you select on.

Name the pattern first; the change follows from it.

The task behind today's measurements: S10's XOR-quadrant miniature

Steps 4 and 5 are done (slides 11–19). Step 6 and everything after it measure something, so they need one task with real signal that trains in seconds: S10's XOR-quadrant data (reading 2.4–2.5). torch and nn as before.


import numpy as np
def make_data(n, rng):
    X = rng.normal(0.0, 0.5, size=(n, 2)).astype(np.float32)
    y = (X[:, 0] * X[:, 1] > 0).astype(np.int64)
    X += rng.normal(0.0, 0.1, size=X.shape).astype(np.float32)
    return torch.from_numpy(X), torch.from_numpy(y)
def build_model(width=32, depth=2):
    layers, d_in = [], 2
    for _ in range(depth):
        layers += [nn.Linear(d_in, width), nn.ReLU()]
        d_in = width
    layers.append(nn.Linear(d_in, 2))
    return nn.Sequential(*layers)
X, y = make_data(512, np.random.default_rng(0))
print(tuple(X.shape), y.float().mean().item())
      
(512, 2) 0.48828125
\(X\): \((N,2)\); label 1 when \(x_1x_2>0\) before the jitter, an XOR-like (exclusive-or) quadrant pattern
↓
Linear(2, 32), ReLU: \((N,32)\)
↓
Linear(32, 32), ReLU: \((N,32)\)
↓
logits, Linear(32, 2): \((N,2)\)
  • Class 1 is 48.8% of these 512 points, so always answering class 0 scores 0.512: step 2's dumb baseline.

Two balanced classes, so the step-0 loss should sit near \(\ln2\approx0.693\).

Choose the learning rate from evidence: one short run per rate

Step 6 tunes the learning rate first: no other hyperparameter moves outcomes more. make_data, build_model from the previous page; plain SGD.


def train_sgd(lr, steps):
    rng = np.random.default_rng(0)
    torch.manual_seed(0)
    X, y = make_data(512, rng)
    model = build_model()
    opt = torch.optim.SGD(model.parameters(), lr=lr)
    loss_fn = nn.CrossEntropyLoss()
    losses = []
    for step in range(steps):
        idx = torch.randint(0, len(X), (64,))
        opt.zero_grad()
        loss = loss_fn(model(X[idx]), y[idx])
        loss.backward()
        opt.step()
        losses.append(loss.item())
    return losses
    
  • Every call reseeds, so every run sees the same 512 points, the same starting weights and the same minibatches of 64. Two runs differ only in lr.
  • Leslie Smith's learning-rate range test (2015) is a different tool: one run whose rate starts low and rises at every step; lr_find in common libraries runs it and plots loss against rate. A sweep makes independent short runs, one per rate, each from the same start.

Each call is one complete, reproducible run that returns its minibatch loss at every step.

Too high a rate thrashes, mostly above its starting loss, without a NaN

train_sgd (previous page), 401 steps at 0.1 and at 10: the code prints every 100th loss, the figure plots all 401.


for lr in [0.1, 10.0]:
    losses = train_sgd(lr, 401)
    print(f"lr = {lr:4}:", ", ".join(f"{v:.4f}" for v in losses[::100]))
  

lr =  0.1: 0.6935, 0.5455, 0.4205, 0.3239, 0.2923
lr = 10.0: 0.6935, 0.9322, 4.8313, 2.5282, 2.2738
      
  • Both start at 0.6935, near \(\ln2\). At 0.1 the loss falls steadily to 0.2923.
  • At 10 it jumps above 10 by step 2, then thrashes between about 0.7 and 5; over steps 3–400 its median is 2.28, more than three times its start. No step is NaN.
  • This reads as "not learning" and gets treated with more steps.
lr = 0.1 lr = 10 step-0 loss 0.6935 0.3 1 3 10 0 100 200 300 400 step loss, log scale

A loss that hovers above its step-0 value points first at a rate that is too high, not at too few steps.

The sweep: 60 steps per rate, lowest mean loss at 0.3

Seven independent train_sgd runs across four decades of rate, each scored by the mean loss of its last 10 steps.


for lr in [1e-3, 1e-2, 1e-1, 3e-1, 1.0, 3.0, 10.0]:
    losses = train_sgd(lr, 60)
    tail = np.mean(losses[-10:])
    print(f"  lr = {lr:6.3f}: mean loss of last 10 steps = {tail:.4f}")
  

  lr =  0.001: mean loss of last 10 steps = 0.6883
  lr =  0.010: mean loss of last 10 steps = 0.6821
  lr =  0.100: mean loss of last 10 steps = 0.6306
  lr =  0.300: mean loss of last 10 steps = 0.5165
  lr =  1.000: mean loss of last 10 steps = 0.5471
  lr =  3.000: mean loss of last 10 steps = 0.7944
  lr = 10.000: mean loss of last 10 steps = 2.1409
      
  • 0.001 and 0.01 barely leave the start in 60 steps: too low. 3 and 10 end above it: too high.
0.5 1.0 1.5 2.0 0.001 0.01 0.1 1 10 learning rate, log scale mean of the last 10 losses step-0 loss 0.6935 lowest: lr = 0.3

Lowest at 0.3 on this seed; seeds 1–4 put it at 1.0. A sweep finds the band (0.3 to 1), not the point. Each optimizer needs its own sweep: Adam's band usually sits near \(10^{-3}\).

Six common symptoms, each with a first hypothesis and a check

The recipe's tests catch the most common bugs; these symptoms point at the rest (reading 2.4).

symptomfirst hypothesischeck
Step-0 loss far from \(\ln K\) (\(K\) classes) when predictions should be uniformoutput scale, initialization, loss wiringclass balance; the tensor the loss receives
Loss with no trend from step 0 (exactly flat on one fixed batch)no weight is updated: parameters missing from the optimizer, or no opt.step()the optimizer's parameter list; one weight before and after a step
Loss NaN after some stepsa rate too high, an unstable operation, exploding gradientslog the gradient norm; lower the rate
Accuracy stuck at the class prior (the share of the most common class)the model predicts one classconfusion matrix, label balance
Validation much better than trainingtraining-only augmentation or dropout, or leakagetraining accuracy in evaluation mode; duplicates across the split
Training and validation fine, test poordistribution or pipeline shifttest preprocessing against validation's

Each one is checkable in minutes.

The permuted-label test: scrambled labels must score at chance

Leakage (the previous page's fifth row) makes a number better and often shows no symptom; this test finds it.

  • Scramble the labels once: each input keeps a label, but the labels carry no information.
  • A model with enough capacity still memorizes them (Zhang et al., 2017), so high training accuracy proves nothing.
  • On fresh validation data it must then score at chance. Above chance means information about validation examples reaches training: a leak.

A pipeline duplicates every example (2x oversampling), then splits the rows at random into training and validation. With scrambled labels, does validation still score at chance?

No. About half the validation points have an identical twin, same input and same label, in training; a model that memorized the twin gets the copy right. The test sees this only if the labels are scrambled before the pipeline copies or splits anything.

The test in code: train, then report both accuracies

make_data, build_model from the task page, with the network 256 units wide so that it can memorize.


def fit(Xtr, ytr, Xva, yva, steps=4000):
    torch.manual_seed(0)
    model = build_model(width=256)
    opt = torch.optim.Adam(model.parameters(), lr=1e-3)
    loss_fn = nn.CrossEntropyLoss()
    for _ in range(steps):
        idx = torch.randint(0, len(Xtr), (64,))
        opt.zero_grad()
        loss_fn(model(Xtr[idx]), ytr[idx]).backward()
        opt.step()
    with torch.no_grad():
        tr = (model(Xtr).argmax(1) == ytr).float().mean().item()
        va = (model(Xva).argmax(1) == yva).float().mean().item()
    return round(tr, 4), round(va, 4)          # (training acc, validation acc)
  
  • Run it once the pipeline works end to end (step 3), and again whenever the data handling changes: new splits, new preprocessing, merged datasets. Split first, then oversample.

Overfit one batch checks the model and the loop; this test checks the split for leaks.

A leak, measured: 0.6375 on labels that carry no information

fit from the previous page; each line prints (training accuracy, validation accuracy).


torch.manual_seed(0)
rng = np.random.default_rng(0)
Xtr, ytr = make_data(64, rng)
Xva, yva = make_data(1000, rng)
y_perm = ytr[torch.randperm(64)]               # the same labels, scrambled once
print("true labels:     ", fit(Xtr, ytr, Xva, yva))
print("permuted labels: ", fit(Xtr, y_perm, Xva, yva))
X, y = make_data(400, rng)
y_shuf = y[torch.randperm(400)]                 # scrambled at the source, before copying
Xd, yd = torch.cat([X, X]), torch.cat([y_shuf, y_shuf])   # BUG: oversample 2x ...
idx = torch.randperm(800)                       # ... then split: copies land on both sides
tr, va = idx[:400], idx[400:]
print("leaky pipeline:  ", fit(Xd[tr], yd[tr], Xd[va], yd[va]))
  

true labels:      (1.0, 0.834)
permuted labels:  (1.0, 0.509)
leaky pipeline:   (0.8375, 0.6375)
  

Scrambled labels give validation 0.509, chance: the healthy signature. The leaky pipeline scores 0.6375 on labels with no information.

A seed is a sample: run the same experiment five times

The second half: once the pipeline is right, is a better number real? Debugging fixes one seed so that runs are comparable; measuring a result takes several. run_experiment is one complete run, data included, under one seed (make_data, build_model from the task page; Adam at its usual \(10^{-3}\), 400 steps).


def run_experiment(seed):
    rng = np.random.default_rng(seed)
    torch.manual_seed(seed)
    Xtr, ytr = make_data(256, rng)
    Xte, yte = make_data(2000, rng)
    model = build_model()
    opt = torch.optim.Adam(model.parameters(), lr=1e-3)
    loss_fn = nn.CrossEntropyLoss()
    for step in range(400):
        idx = torch.randint(0, len(Xtr), (64,))
        opt.zero_grad()
        loss_fn(model(Xtr[idx]), ytr[idx]).backward()
        opt.step()
    with torch.no_grad():
        return (model(Xte).argmax(1) == yte).float().mean().item()
  
  • The seed fixes every random draw: the 256 training and 2,000 test points, the starting weights, the minibatch order.
  • Augmentation, dropout masks and a random data split draw randomness too.
  • Each seed draws one model from the distribution the setup defines.

The function returns one test accuracy: one sample.

The same seed twice: identical. Five seeds: a 2.25-point spread

Call run_experiment (previous page) twice with seed 0, then for seeds 0 to 4. ddof=1 divides by \(n-1\).


print(f"seed 0, run 1: {run_experiment(0):.4f}")
print(f"seed 0, run 2: {run_experiment(0):.4f}")
accs = [run_experiment(s) for s in range(5)]
print("seeds 0-4:", ", ".join(f"{a:.4f}" for a in accs))
print(f"mean = {np.mean(accs):.4f}, std = {np.std(accs, ddof=1):.4f}, "
      f"spread = {max(accs) - min(accs):.4f}")
  

seed 0, run 1: 0.8895
seed 0, run 2: 0.8895
seeds 0-4: 0.8895, 0.8720, 0.8670, 0.8865, 0.8670
mean = 0.8764, std = 0.0108, spread = 0.0225
    
  • Same seed: bitwise-identical.
  • Different seeds: 0.8670 to 0.8895, from randomness alone, the test draw included.
  • A lucky run against an unlucky one "wins" by 2 points of noise, which is not a finding.
  • On GPUs some CUDA/cuDNN kernels are nondeterministic even when seeded; library versions also change results, so record them with the seed.

Defense: 3–5 seeds per configuration, reporting the mean and the variation.

Fair comparisons: the equal-budget rule

Seed variance is the visible unfairness; the tuning budget is the invisible one.

  • Equal tuning budgets: the same number of configurations, chosen the same way, on the same validation data. Report the budget. Two weeks tuning your method against a baseline at its defaults measures effort, not merit.
  • Identical everything else: splits, preprocessing, augmentation, evaluation code, early-stopping rule. Each extra difference is a confound.
  • Tune on validation, report on test, touch test once. Each look at test during development turns it into validation data.
  • Decide metrics in advance; choosing one later is a multiple-comparisons error.
  • Field history (reading 2.5): Henderson et al. 2018, Melis et al. 2018, Dodge et al. 2019.

Under these rules "the simple baseline won" is a successful experiment: the project's fair-comparison policy.

Ablations: every choice in a config, every run a (config, seed) pair

A result says that a system works; an ablation says why, by removing or simplifying one component at a time. Reading 2.5 moves each choice of run_experiment into a dictionary (make_data, build_model from the task page).


def run(config, seed):
    rng = np.random.default_rng(seed)
    torch.manual_seed(seed)
    Xtr, ytr = make_data(256, rng)
    Xte, yte = make_data(2000, rng)            # test: measured here, never used to choose
    model = build_model(config["width"], config["depth"])
    opt = torch.optim.Adam(model.parameters(), lr=config["lr"])
    loss_fn = nn.CrossEntropyLoss()
    for step in range(config["steps"]):        # equal budget for every variant
        idx = torch.randint(0, len(Xtr), (config["batch_size"],))
        opt.zero_grad()
        loss_fn(model(Xtr[idx]), ytr[idx]).backward()
        opt.step()
    with torch.no_grad():
        acc = (model(Xte).argmax(1) == yte).float().mean().item()
    return {"config": config, "seed": seed, "test_acc": round(acc, 4)}
  

The returned record, config plus seed plus metric, is what gets logged.

Three variants, the same three seeds, one JSON line per run

run from the previous page; JSON (JavaScript Object Notation) lines are written with json, in Python's standard library. Each variant changes one entry of base.


import json
base = {"width": 32, "depth": 2, "lr": 1e-3, "steps": 400, "batch_size": 64}
variants = {"baseline": dict(base), "half width": dict(base, width=16),
            "half depth": dict(base, depth=1)}  # a copy of base with depth replaced
with open("ablation_log.jsonl", "w") as f:
    for name, config in variants.items():
        accs = []
        for seed in range(3):                  # same 3 seeds for every variant
            record = run(config, seed) | {"variant": name}  # | adds a "variant" key
            f.write(json.dumps(record) + "\n")    # one line per run; "w" starts a new log
            accs.append(record["test_acc"])
        print(f"{name:15s} acc = {np.mean(accs):.4f} +/- {np.std(accs, ddof=1):.4f} "
              f"(seeds: {', '.join(f'{a:.3f}' for a in accs)})")
with open("ablation_log.jsonl") as f:
    print("first log line:")
    print(f.readline().strip())
  

Change one thing at a time, as when debugging: one entry per variant, the budget equal, the seeds reused.

Half width: within noise. Half depth: suggestive, not settled

The printout of the previous page (reading 2.5); +/- is the standard deviation over the 3 seeds.


baseline        acc = 0.8762 +/- 0.0118 (seeds: 0.889, 0.872, 0.867)
half width      acc = 0.8692 +/- 0.0098 (seeds: 0.880, 0.864, 0.863)
half depth      acc = 0.8570 +/- 0.0193 (seeds: 0.876, 0.857, 0.838)
first log line:
{"config": {"width": 32, "depth": 2, "lr": 0.001, "steps": 400, "batch_size": 64}, "seed": 0, "test_acc": 0.8895, "variant": "baseline"}
  
  • Half width costs 0.7 points (0.8762 to 0.8692), but the standard deviations are about a point: within noise at 3 seeds, and an honest write-up says so.
  • Half depth (one hidden layer instead of two) costs 1.9 points, above the baseline's standard deviation (1.2) but not its own (1.9): worth more seeds.
  • On seed 0 alone the three look alike: 0.889, 0.880, 0.876.
  • "The difference is within seed noise at our budget" is itself a correct result.

Averages with spreads, or no conclusions.

Configs, logs, one-command reruns

The ablation's structure, generalized, and the pattern to copy for your project: keep the last working configuration, made mechanical, so that someone else, or you later, can check a result.

  • One config object holds every choice. A CPU run is then a pure function of (config, seed): the ablation's baseline at seed 0 logs 0.8895, the seed study's seed-0 number. An edit between runs that no config records is how you lose your own best number.
  • Append (open the log with "a") one JSON-lines record per run: the config, the seed, the metrics, the git commit hash (git rev-parse HEAD) and the library versions.
  • Never overwrite results. Generate the report's tables from the log; numbers drift when they are copied by hand.
  • Make reruns one command, including for you, if you are called for a code defense.

When a result looks surprising, rerun its exact (config, seed) from the log first: one rerun separates "my code changed" from "I misremembered".

Self-check: a thrashing loss, a one-seed win, a drifting frozen backbone

One question for each part of today: the recipe's checks, seeds, and a frozen trunk with BatchNorm.

(a) Your loss starts at 0.69, jumps to 3 within a few steps and hovers between 1 and 4 for 400 steps, never NaN. Too few steps? What run do you make next?

(b) Your change beats the baseline by 0.8 points on seed 0; over seeds 0–4 the baseline's standard deviation is 1.1 points. A finding?

(c) You freeze a BatchNorm backbone (all its parameters requires_grad=False) and train a new head. Its features for a fixed image change across epochs. Why?

(a) The rate is too high: the loss hovers above its step-0 value (slide 22). Sweep the rate across decades with short runs and pick from the band (slide 23).

(b) No: 0.8 is inside one standard deviation. Run both on the same 3–5 seeds with equal tuning budgets; compare the means with their spread.

(c) BN's running statistics are buffers, not parameters, and the outer model.train() puts BN in training mode, so they move. Call trunk.eval() after it.

For next time

  • Read: 3.1 RNNs and LSTMs. Next session (S13) starts Module 3: recurrent neural networks (RNNs) for sequences.
  • Quiz 2 next session (S13): the final 30 minutes, in Canvas; it covers Module 2, S09–S12. To prepare: the S15 review guide (its S09–S12 blocks) and the Self-check pages of S09–S12. (Online (Web): your window is on the Canvas calendar.)
  • HW2 is out today, due at S18, Wednesday of the week after next. Start at step 1: look at make_synthetic_images' output, then overfit one batch with your SmallCNN; the tests fix the learning rate.
  • Project pre-proposal (≤1 page: question, data, baseline, metric) due at the end of this week (the S13 week); every member submits the same PDF. The proposal (end of the S23 week, four weeks after the pre-proposal) adds how you will estimate uncertainty.