Training Well
Knowing backpropagation is not the same as getting a network to learn. This page moves you from NumPy to PyTorch and walks through the craft that decides whether training works: the loop, the optimiser, the learning rate, initialisation, regularisation, normalisation, and a recipe for when it all goes wrong.
Two engineers build the same network, on the same data, with the same code for backpropagation. One gets 97% accuracy in a minute. The other watches the loss sit at 2.30 for an hour, then turn into nan. Nothing in the mathematics separates them. What separates them is a dozen small decisions — how the weights start, how big each step is, when to stop, what to do with a loss that will not move — that textbooks treat as footnotes and practitioners treat as the job. This page is about those decisions.
The training loop in PyTorch
On Neural Networks from Scratch and Backpropagation and Gradient Descent you wrote the forward pass, the gradients and the update by hand in NumPy. That is the right way to understand a network and the wrong way to build one: every new layer means new derivative code, and one sign error silently ruins everything. PyTorch keeps the ideas and removes the bookkeeping. Its arrays are called tensors — NumPy arrays that can also live on a GPU and that remember how they were computed, so PyTorch can run backpropagation for you (this is called autograd).
Here is a complete classifier for scikit-learn’s 8×8 handwritten digits: 1,797 images, ten classes, a small network with one hidden layer of 128 units. It runs in a few seconds on an ordinary laptop CPU.
import torch
from torch import nn
from torch.utils.data import TensorDataset, DataLoader
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
torch.manual_seed(0)
X, y = load_digits(return_X_y=True) # 1,797 images, 8x8 = 64 pixels each
X_tr, X_va, y_tr, y_va = train_test_split(X / 16.0, y, test_size=0.25, random_state=42)
X_tr, X_va = torch.tensor(X_tr, dtype=torch.float32), torch.tensor(X_va, dtype=torch.float32)
y_tr, y_va = torch.tensor(y_tr), torch.tensor(y_va) # int64 class labels
loader = DataLoader(TensorDataset(X_tr, y_tr), batch_size=64, shuffle=True)
model = nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 10))
loss_fn = nn.CrossEntropyLoss()
opt = torch.optim.SGD(model.parameters(), lr=0.1)
for epoch in range(1, 11):
model.train()
total = 0.0
for xb, yb in loader:
opt.zero_grad() # 1. clear last step's gradients
loss = loss_fn(model(xb), yb) # 2. forward pass and loss
loss.backward() # 3. backprop fills every .grad
opt.step() # 4. nudge the weights
total += loss.item() * len(xb)
model.eval()
with torch.no_grad():
logits = model(X_va)
va_loss = loss_fn(logits, y_va).item()
va_acc = (logits.argmax(1) == y_va).float().mean().item()
print(f"epoch {epoch:2d} train loss {total / len(X_tr):.3f} "
f"val loss {va_loss:.3f} val acc {va_acc:.3f}")
epoch 1 train loss 2.225 val loss 2.101 val acc 0.458
epoch 2 train loss 1.976 val loss 1.806 val acc 0.644
epoch 3 train loss 1.637 val loss 1.426 val acc 0.767
epoch 4 train loss 1.271 val loss 1.145 val acc 0.840
epoch 5 train loss 0.975 val loss 0.889 val acc 0.802
epoch 6 train loss 0.770 val loss 0.727 val acc 0.822
epoch 7 train loss 0.624 val loss 0.594 val acc 0.876
epoch 8 train loss 0.525 val loss 0.498 val acc 0.889
epoch 9 train loss 0.452 val loss 0.425 val acc 0.913
epoch 10 train loss 0.399 val loss 0.472 val acc 0.867
Run with PyTorch 2.14.1 (CPU build) on Python 3.13 As of October 2026. With the same seeds you should see the same numbers on the same versions; other machines, thread counts or PyTorch releases can differ slightly in the last digits.
Read the loop as five ideas:
- Tensors and a
DataLoader.TensorDatasetpairs inputs with labels;DataLoaderserves them in shuffled mini-batches of 64. Each pass over the whole training set is an epoch. For data that does not fit in memory you would write your ownDatasetclass with__len__and__getitem__; the loop stays the same. - The model.
nn.Sequentialstacks a linear layer (64 → 128), a ReLU and another linear layer (128 → 10). The ten outputs are raw scores called logits, one per digit. - The loss.
nn.CrossEntropyLossapplies the softmax and the log itself, so you must give it raw logits, not probabilities, and integer class labels. - The four-line heart.
zero_grad()clears the previous step’s gradients (PyTorch adds new gradients to old ones unless told otherwise), the forward pass computes the loss,backward()fills each parameter’s.grad, andstep()moves the weights. Every PyTorch training loop, including those for large language models, has these four lines inside it. - Train and eval modes.
model.train()andmodel.eval()switch layers such as dropout and batch normalisation between training and inference behaviour;torch.no_grad()stops PyTorch recording operations you will not differentiate.
Look at the output. Validation accuracy climbs from 46% to 91% by epoch 9, then drops to 87% at epoch 10 even though training loss kept falling. That wobble is normal with plain SGD and a fairly large step, and it is exactly the kind of behaviour the rest of this page teaches you to read and control.
Optimisers: SGD, momentum and Adam
An optimiser is the rule that turns gradients into weight changes. Plain stochastic gradient descent (SGD) does the simplest thing: θ ← θ − η · ∇L, where θ stands for all the weights and η (eta) is the learning rate. “Stochastic” because each gradient comes from a random mini-batch, not the whole dataset, so it is a noisy estimate of the true downhill direction.
Noise and awkward landscapes are SGD’s two problems. In a long narrow valley, the gradient points mostly across the valley rather than along it, so SGD zig-zags from wall to wall and crawls forward. Momentum fixes this by keeping a running “velocity” that remembers past gradients:
θ ← θ − η · vwhere v is the velocity, β (often 0.9) is how much of the old velocity survives each step, η is the learning rate and ∇L(θ) is the gradient of the loss. This is the form PyTorch’s
SGD(momentum=…) uses; some texts put η inside the first line instead.In words: the zig-zag components point in opposite directions on alternate steps and cancel out in the running sum, while the consistent “along the valley” component adds up. With β = 0.9, steps along a steady direction become up to ten times larger. Picture a heavy ball rolling downhill.
One learning rate, no memory. Simple, cheap and well understood. With a carefully tuned learning-rate schedule it still trains many vision models to excellent accuracy, and some practitioners find its solutions generalise slightly better. Its weakness is sensitivity: the right learning rate depends on the model, the batch size and the data, and the same rate is used for every weight.
Adds a velocity. Damps zig-zags and speeds up progress along consistent directions. The standard choice for training convolutional networks with SGD; β = 0.9 is the usual default. The Nesterov variant (nesterov=True) looks one step ahead before measuring the gradient and is often slightly better.
A per-weight, self-adjusting step. Adam (Kingma and Ba, 2015) keeps two running averages for every weight: m, a momentum-like average of the gradient (β1 = 0.9), and v, an average of the gradient squared (β2 = 0.999). The update is roughly η · m ÷ (√v + ε), after correcting both averages for starting at zero. Dividing by √v gives each weight its own scale: a weight whose gradients are always huge takes modest steps; a weight with tiny, rare gradients takes relatively bold ones. That is why Adam with its default learning rate of 0.001 works “out of the box” far more often than SGD does.
The same network, the same starting weights, five epochs, different optimisers:
import torch
from torch import nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
X, y = load_digits(return_X_y=True)
X_tr, X_va, y_tr, y_va = train_test_split(X / 16.0, y, test_size=0.25, random_state=42)
X_tr, X_va = torch.tensor(X_tr, dtype=torch.float32), torch.tensor(X_va, dtype=torch.float32)
y_tr, y_va = torch.tensor(y_tr), torch.tensor(y_va)
def run(make_opt, epochs=5):
torch.manual_seed(0) # same starting weights for every optimiser
model = nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 10))
opt, loss_fn = make_opt(model.parameters()), nn.CrossEntropyLoss()
for epoch in range(epochs):
perm = torch.randperm(len(X_tr))
for i in range(0, len(X_tr), 64): # mini-batches of 64, by hand
idx = perm[i:i + 64]
opt.zero_grad()
loss_fn(model(X_tr[idx]), y_tr[idx]).backward()
opt.step()
with torch.no_grad():
return loss_fn(model(X_va), y_va).item(), (model(X_va).argmax(1) == y_va).float().mean().item()
optimisers = {
"SGD lr=0.01": lambda p: torch.optim.SGD(p, lr=0.01),
"SGD lr=0.1": lambda p: torch.optim.SGD(p, lr=0.1),
"SGD+momentum 0.9 lr=0.01": lambda p: torch.optim.SGD(p, lr=0.01, momentum=0.9),
"Adam lr=0.001": lambda p: torch.optim.Adam(p, lr=0.001),
"Adam lr=0.01": lambda p: torch.optim.Adam(p, lr=0.01),
"SGD lr=5.0": lambda p: torch.optim.SGD(p, lr=5.0),
}
for name, make in optimisers.items():
loss, acc = run(make)
print(f"{name:26s} after 5 epochs: val loss {loss:.3f} val acc {acc:.3f}")
SGD lr=0.01 after 5 epochs: val loss 2.211 val acc 0.398
SGD lr=0.1 after 5 epochs: val loss 0.847 val acc 0.856
SGD+momentum 0.9 lr=0.01 after 5 epochs: val loss 0.992 val acc 0.882
Adam lr=0.001 after 5 epochs: val loss 0.729 val acc 0.907
Adam lr=0.01 after 5 epochs: val loss 0.130 val acc 0.964
SGD lr=5.0 after 5 epochs: val loss 2.555 val acc 0.096
Three lessons sit in those six lines. First, the learning rate matters as much as the optimiser: SGD at 0.01 has barely started (40%), SGD at 0.1 is respectable (86%), SGD at 5.0 has blown up and is guessing (9.6%, worse than the 10% you would get by chance). Second, momentum at 0.01 beats plain SGD at ten times that rate, because the velocity multiplies the effective step. Third, Adam at 0.01 is best here (96.4%). Do not over-read a single small run, but this pattern — Adam is forgiving, SGD needs tuning — is typical. A widely used variant, AdamW, changes only how weight decay is applied (see below) and is the default optimiser for training Transformers As of 2026.
Learning-rate schedules
The best learning rate is not constant. Early on, big steps cover ground fast; near the end, small steps settle into a good minimum instead of bouncing around it. A learning-rate schedule changes η as training proceeds. Drag the slider below to feel the trade-off a schedule is trying to manage: small rates crawl, large ones overshoot.
Common schedules, all available in torch.optim.lr_scheduler:
- Step decay (
StepLR): divide the rate by 10 every so many epochs. The classic recipe for ResNets. - Cosine annealing (
CosineAnnealingLR): glide smoothly from the starting rate to near zero along half a cosine curve (Loshchilov and Hutter, 2017). - Warm-up: start tiny and increase linearly for the first few hundred or thousand steps, then decay. Adam’s early estimates of v are unreliable and very deep networks are fragile at the start; warm-up protects both. Language-model training almost always uses warm-up followed by cosine or linear decay.
- Reduce on plateau (
ReduceLROnPlateau): cut the rate whenever validation loss stops improving.
import torch
from torch import nn
model = nn.Linear(4, 2) # any model will do
opt = torch.optim.SGD(model.parameters(), lr=0.1)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=10)
for epoch in range(10):
lr = opt.param_groups[0]["lr"]
# ... one epoch of training would go here ...
opt.step() # (normally inside the batch loop)
sched.step() # once per epoch, after the optimiser
print(f"epoch {epoch}: lr {lr:.4f}")
epoch 0: lr 0.1000
epoch 1: lr 0.0976
epoch 2: lr 0.0905
epoch 3: lr 0.0794
epoch 4: lr 0.0655
epoch 5: lr 0.0500
epoch 6: lr 0.0345
epoch 7: lr 0.0206
epoch 8: lr 0.0095
epoch 9: lr 0.0024
Note the order: opt.step() inside the batch loop, sched.step() once per epoch after it. Calling them the other way round is a common mistake; recent PyTorch versions print a warning.
Initialisation: where the weights start
Gradient descent improves weights; it cannot rescue weights that start in a hopeless place. Each layer multiplies its input by a weight matrix. If the weights are slightly too small, the signal shrinks a little at every layer; slightly too large, and it grows. Over twenty layers, “a little” compounds. Run this:
import torch
torch.manual_seed(0)
x = torch.randn(512, 256) # a batch of 512 random inputs
inits = {"std 0.01": lambda n: 0.01,
"std 1.0": lambda n: 1.0,
"He": lambda n: (2 / n) ** 0.5}
for name, std in inits.items():
h = x
for layer in range(1, 21): # 20 layers, 256 units each, ReLU
W = torch.randn(256, 256) * std(256)
h = torch.relu(h @ W)
if layer in (1, 5, 10, 20):
print(f"{name:8s} layer {layer:2d}: activation std {h.std().item():.3g}")
std 0.01 layer 1: activation std 0.0933
std 0.01 layer 5: activation std 1.43e-05
std 0.01 layer 10: activation std 2.88e-10
std 0.01 layer 20: activation std 9.05e-20
std 1.0 layer 1: activation std 9.32
std 1.0 layer 5: activation std 1.49e+05
std 1.0 layer 10: activation std 2.39e+10
std 1.0 layer 20: activation std 4.91e+20
He layer 1: activation std 0.824
He layer 5: activation std 0.784
He layer 10: activation std 0.931
He layer 20: activation std 0.789
With standard deviation 0.01 the activations fall to 10−20 by layer 20: every later layer sees zeros, and the gradients flowing back are just as dead. With standard deviation 1.0 they explode to 1020. The third row stays near 1 all the way down. That is He initialisation (He, Zhang, Ren and Sun, 2015): draw weights with standard deviation √(2 ÷ nin), where nin is the number of inputs to the layer. The 2 compensates for ReLU zeroing out roughly half its inputs. For tanh or sigmoid layers the older Xavier (or Glorot) initialisation (Glorot and Bengio, 2010) uses variance 2 ÷ (nin + nout) instead.
The principle behind both: choose the scale so the variance of the signal is preserved from layer to layer, forwards and backwards. PyTorch’s nn.Linear and nn.Conv2d already start from a sensible fan-in-scaled uniform distribution, so for small networks you rarely need to intervene; torch.nn.init.kaiming_normal_ and xavier_uniform_ are there when you do. Careful initialisation, together with ReLU and the residual connections you will meet on Convolutional Networks, is a large part of why networks of a hundred layers became trainable — the story told on The Deep Learning Revolution.
Regularisation: closing the train–validation gap
A network with enough parameters can memorise its training set — including its mistakes. The gap between training and validation performance is the symptom of overfitting, and regularisation is anything that narrows the gap without simply making the model worse. The three tools every practitioner reaches for:
- Weight decay shrinks every weight slightly towards zero at each step, preferring simpler functions built from smaller weights. In plain SGD it is equivalent to adding λ‖θ‖² to the loss (L2 regularisation). In Adam it is not, because Adam rescales the gradient; Loshchilov and Hutter showed that applying the decay directly to the weights —
AdamW— works better. - Dropout (Srivastava et al., 2014) randomly zeroes each unit with probability p during training, so no unit can rely on any particular partner and the network learns redundant, more robust features. PyTorch scales the survivors by 1 ÷ (1 − p) during training, so at evaluation time (
model.eval()) dropout simply switches off. - Early stopping watches validation performance and keeps the weights from the best epoch, stopping once it has not improved for a while (the “patience”). It costs nothing and is often the most effective of the three.
To make overfitting obvious, the experiment below trains a big network (two hidden layers of 512 units — about 300,000 weights) on only 300 digits, and replaces 30% of the training labels with random ones. Real datasets contain label noise too, though rarely this much. The network can “learn” those wrong labels only by memorising them.
import torch
from torch import nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
X, y = load_digits(return_X_y=True)
X_tr, X_va, y_tr, y_va = train_test_split(X / 16.0, y, train_size=300, random_state=42)
X_tr, X_va = torch.tensor(X_tr, dtype=torch.float32), torch.tensor(X_va, dtype=torch.float32)
y_tr, y_va = torch.tensor(y_tr), torch.tensor(y_va)
g = torch.Generator().manual_seed(1)
noisy = torch.rand(len(y_tr), generator=g) < 0.3 # 30% of training labels...
y_tr[noisy] = torch.randint(0, 10, (int(noisy.sum()),), generator=g) # ...replaced at random
def run(p_drop, wd, epochs=300):
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(64, 512), nn.ReLU(), nn.Dropout(p_drop),
nn.Linear(512, 512), nn.ReLU(), nn.Dropout(p_drop),
nn.Linear(512, 10))
opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=wd)
loss_fn = nn.CrossEntropyLoss()
best_acc, best_epoch = 0.0, 0
for epoch in range(1, epochs + 1):
model.train() # dropout on while learning
opt.zero_grad()
loss_fn(model(X_tr), y_tr).backward() # whole training set as one batch
opt.step()
model.eval() # dropout off while measuring
with torch.no_grad():
va_acc = (model(X_va).argmax(1) == y_va).float().mean().item()
if va_acc > best_acc:
best_acc, best_epoch = va_acc, epoch # what early stopping would keep
with torch.no_grad():
tr_acc = (model(X_tr).argmax(1) == y_tr).float().mean().item()
print(f"dropout {p_drop}, decay {wd}: train acc {tr_acc:.3f} val acc {va_acc:.3f} "
f"gap {tr_acc - va_acc:+.3f} | best val {best_acc:.3f} at epoch {best_epoch}")
run(0.0, 0.0)
run(0.5, 0.0)
run(0.5, 1.0)
dropout 0.0, decay 0.0: train acc 1.000 val acc 0.695 gap +0.305 | best val 0.865 at epoch 22
dropout 0.5, decay 0.0: train acc 0.983 val acc 0.771 gap +0.212 | best val 0.868 at epoch 30
dropout 0.5, decay 1.0: train acc 0.977 val acc 0.776 gap +0.201 | best val 0.868 at epoch 30
Read across the rows. Without regularisation the network reaches 100% training accuracy — it has memorised every wrong label — and validation accuracy collapses to 69.5%, a gap of 30 points. Dropout narrows the gap to 21 points and lifts validation accuracy to 77%. Adding strong weight decay helps only a little more here. But the right-hand column is the striking one: at epoch 22, before memorisation set in, the unregularised network was already at 86.5% on validation. Early stopping would have kept those weights and beaten both regularised runs’ final scores. Over the next 278 epochs the network learned the noise.
Always watch training and validation curves together. Falling training loss tells you the optimiser works; validation tells you whether what it learned is worth having. When they part company, regularise — and keep the best checkpoint, not the last.
Normalisation layers
Initialisation sets a good scale at the start, but weights change during training, and the scale of each layer’s inputs drifts with them. Batch normalisation (Ioffe and Szegedy, 2015) re-standardises inside the network: for each feature it subtracts the mini-batch mean and divides by the mini-batch standard deviation, then applies a learned scale γ and shift β so the network can undo the normalisation if that helps. Layer normalisation (Ba, Kiros and Hinton, 2016) does the same calculation across the features of each single example instead of across the batch.
import torch
from torch import nn
torch.manual_seed(0)
h = torch.randn(64, 4) * 3 + 5 # 64 examples x 4 features, mean ~5, std ~3
bn, ln = nn.BatchNorm1d(4), nn.LayerNorm(4)
fmt = lambda t: " ".join(f"{v:+.2f}" for v in t.tolist())
print("raw mean per feature:", fmt(h.mean(0)))
print("batch norm mean per feature:", fmt(bn(h).mean(0)))
print("batch norm std per feature:", fmt(bn(h).std(0, unbiased=False)))
print("layer norm mean per example:", fmt(ln(h).mean(1)[:4]), "...")
print("layer norm std per example:", fmt(ln(h).std(1, unbiased=False)[:4]), "...")
raw mean per feature: +4.72 +4.96 +5.50 +5.13
batch norm mean per feature: +0.00 +0.00 +0.00 -0.00
batch norm std per feature: +1.00 +1.00 +1.00 +1.00
layer norm mean per example: -0.00 +0.00 +0.00 -0.00 ...
layer norm std per example: +1.00 +1.00 +1.00 +1.00 ...
Batch norm makes every column (feature) mean 0 and standard deviation 1 across the batch; layer norm makes every row (example) mean 0 and standard deviation 1 across its features. The practical differences follow from that:
- Batch norm depends on the batch, so it needs reasonably large batches and behaves differently at inference: in
eval()mode it uses running averages collected during training. Forgettingmodel.eval()before testing is therefore a real bug, not a style point. It is standard in convolutional networks. - Layer norm treats each example independently, so it works with a batch of one and with sequences of varying length. It is what Transformers use (see Transformers in Depth); many recent language models use a cheaper cousin, RMSNorm, which skips subtracting the mean As of 2026.
Consensus Normalisation layers let you train faster, with higher learning rates and less sensitivity to initialisation. Debated Why they work is less settled.
- Internal covariate shift: Ioffe and Szegedy argued batch norm helps by stopping each layer’s input distribution from shifting as earlier layers learn.
- Smoother landscape: Santurkar and colleagues (2018) found that injecting distribution shift after batch norm barely hurt it, and argued that its real benefit is making the loss surface smoother, so larger steps are safe.
- Several effects at once: others point to its implicit regularisation (each example is normalised with slightly different batch statistics) and its interaction with weight decay. The mechanisms are probably not exclusive.
A debugging recipe
Neural networks fail quietly. A bug rarely crashes the program; it produces a model that trains to 85% when it should reach 97%, and you never find out. The defence is a fixed order of checks, each cheap, each ruling out a whole family of mistakes. This sequence is adapted from Andrej Karpathy’s widely shared “A Recipe for Training Neural Networks” (2019) and everyday practice. Work through it top to bottom:
- Look at the dataPrint a handful of inputs with their labels, and plot a few images. Check the scale (0–1 or 0–16?), the class balance and that labels match inputs after shuffling and splitting.
- Check every shapePrint input, output and label shapes for one batch. A label tensor of shape (32, 1) where (32,) is expected can broadcast silently into nonsense.
- Check the loss at step zeroWith ten balanced classes and a random network, cross-entropy should start near ln 10 ≈ 2.30. Much higher means your initial outputs are overconfident; exactly 2.30 for ever means nothing is learning.
- Overfit one batchTrain on a single small batch until the loss is almost zero. If a network cannot memorise 32 examples, there is a bug in the model, the loss or the update — no amount of data will fix it.
- Beat a simple baselineTrain on the full data and compare with logistic regression or a tiny model. A deep network that cannot beat it is suspect.
- Watch both curvesPlot training and validation loss every epoch. Diverging to nan: learning rate too high. Flat: too low, or dead units. Training falls, validation rises: overfitting.
- Only then tune and regulariseAdjust the learning rate first (it matters most), then add regularisation, then change the architecture — one thing at a time, with notes.
Steps 2 to 4 take ten lines of code:
import torch
from torch import nn
from sklearn.datasets import load_digits
torch.manual_seed(0)
X, y = load_digits(return_X_y=True)
xb = torch.tensor(X[:32] / 16.0, dtype=torch.float32) # ONE batch of 32
yb = torch.tensor(y[:32])
model = nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 10))
print("input", tuple(xb.shape), "-> logits", tuple(model(xb).shape), "labels", tuple(yb.shape))
opt = torch.optim.Adam(model.parameters(), lr=1e-2)
loss_fn = nn.CrossEntropyLoss()
print(f"loss before training {loss_fn(model(xb), yb).item():.3f} (ln 10 = 2.303)")
for step in range(1, 201):
opt.zero_grad()
loss = loss_fn(model(xb), yb)
loss.backward()
opt.step()
if step in (1, 10, 50, 200):
print(f"step {step:3d}: loss {loss.item():.4f}")
input (32, 64) -> logits (32, 10) labels (32,)
loss before training 2.323 (ln 10 = 2.303)
step 1: loss 2.3230
step 10: loss 0.5432
step 50: loss 0.0005
step 200: loss 0.0001
The shapes are right, the starting loss of 2.323 is close to ln 10 = 2.303, and the loss falls to 0.0001 on the single batch — the machinery works. The bugs this catches most often are: forgetting zero_grad() (gradients accumulate and steps grow ever larger), applying a softmax before CrossEntropyLoss (which then applies its own), labels of the wrong type or shape, and forgetting model.eval() before measuring.
Reproducibility
Training is full of randomness: initial weights, the shuffle order, dropout masks. To get the same result twice, seed every random number generator you use:
import random
import numpy as np
import torch
def set_seed(seed=42):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed) # seeds the CPU (and any GPUs)
torch.use_deterministic_algorithms(True) # error if an op cannot be deterministic
set_seed(42); a = torch.nn.Linear(3, 1).weight
set_seed(42); b = torch.nn.Linear(3, 1).weight
print(a)
print("identical:", torch.equal(a, b))
Parameter containing:
tensor([[ 0.4414, 0.4792, -0.1353]], requires_grad=True)
identical: True
Two identically seeded layers start with identical weights. On a CPU, seeding is usually enough for repeat runs on the same machine and version; use_deterministic_algorithms(True) makes PyTorch raise an error rather than silently use an operation with no deterministic version. In larger projects, a multi-worker DataLoader needs a seeded generator, and GPUs need extra settings described in the PyTorch reproducibility notes — which are frank that results are not guaranteed to match across releases or platforms, because different CPUs add numbers in different orders.
This guide’s framing Treat a single run as an anecdote. Before you believe that dropout or a new optimiser “helps”, run three or more seeds and look at the spread; on small datasets like these, the difference between seeds can be as large as the effect you are measuring.
Your validation accuracy is excellent during training but drops sharply when you test the saved model in a separate script. The model uses dropout and batch norm. What is the likeliest cause?
In training mode, dropout keeps zeroing units and batch norm uses the current batch’s statistics. model.eval() switches both to their inference behaviour.
Twenty ReLU layers, and activations shrink to almost zero by the last layer. Which change addresses the cause most directly?
The signal is being scaled down at every layer. He initialisation chooses the weight scale that keeps the variance roughly constant through ReLU layers.
Your network cannot get the loss on a single batch of 32 examples close to zero. What should you conclude?
Any reasonable network can memorise 32 examples. If it cannot, more data or regularisation will only hide the problem.
In the noisy-label experiment, what would early stopping have done?
Validation accuracy peaked at 86.5% at epoch 22; everything after that was memorisation of noise.
Take the first training loop on this page and turn it into a proper experiment. Add nn.Dropout(0.2) after the ReLU, switch to torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2), add a CosineAnnealingLR over 30 epochs, and keep a copy of the weights with the best validation loss (copy.deepcopy(model.state_dict())). Run it with seeds 0, 1 and 2 and report the mean and spread of the best validation accuracy. Then remove one change at a time to see which actually mattered.
// Pause and reflect
- In the noisy-label experiment, early stopping beat dropout and weight decay. Would you expect that to hold with clean labels and ten times more data? Why or why not?
- Adam adapts a step size for every weight. What might you lose by letting the optimiser make that choice instead of tuning SGD yourself?
- Which step of the debugging ladder do you think you would be most tempted to skip — and what bug would that let through?
Your notes are saved privately in this browser.
Key terms on this page
Sources & further reading
- Diederik P. Kingma & Jimmy Ba, “Adam: A Method for Stochastic Optimization”, ICLR (2015).
- Nitish Srivastava, Geoffrey Hinton, Alex Krizhevsky, Ilya Sutskever & Ruslan Salakhutdinov, “Dropout: A Simple Way to Prevent Neural Networks from Overfitting”, Journal of Machine Learning Research 15 (2014).
- Sergey Ioffe & Christian Szegedy, “Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift”, ICML (2015); Jimmy Lei Ba, Jamie Ryan Kiros & Geoffrey Hinton, “Layer Normalization” (2016).
- Kaiming He, Xiangyu Zhang, Shaoqing Ren & Jian Sun, “Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification”, ICCV (2015); Xavier Glorot & Yoshua Bengio, “Understanding the difficulty of training deep feedforward neural networks”, AISTATS (2010).
- Ilya Loshchilov & Frank Hutter, “Decoupled Weight Decay Regularization”, ICLR (2019); and “SGDR: Stochastic Gradient Descent with Warm Restarts”, ICLR (2017).
- Shibani Santurkar, Dimitris Tsipras, Andrew Ilyas & Aleksander Mądry, “How Does Batch Normalization Help Optimization?”, NeurIPS (2018).
- Andrej Karpathy, “A Recipe for Training Neural Networks” (blog post, 2019).
- PyTorch documentation: Optimizing Model Parameters (tutorial), torch.optim and Reproducibility As of 2026.