When the loss will not go down
By the end: You have a checklist for a training run that is not learning, and can work through it in order.
- Check the loss at step zero
- Overfit one batch
- Look at the data the model sees
- Learning rate and gradient norms
- NaNs and where they come from
New to pytorch? PyTorch is the Python library almost everyone uses to build and train neural networks. It hands you two things you would otherwise have to write: arrays of numbers that can live on a GPU instead of the main processor, and automatic differentiation — it watches the arithmetic you perform and works out on its own how every weight should move to reduce the error. Layers, optimisers and data loading come with it. Why it matters
A training run that is not learning tells you almost nothing about why, and the temptation is to start changing the architecture. This section is the checklist to work through instead, in order: is the loss at step zero what uniform guessing would give, can the model overfit a single batch, does the data look right when you decode it back, is the learning rate within a factor of ten, and do gradients reach the parameters at all. Each step rules out a whole class of cause, and the answer is almost never the architecture.
In this order#
- 1.Initial loss: close to log of the number of classes for classification? If not, check initialisation and labels.
- 2.Overfit a single batch: train on the same small batch for a few hundred steps. The loss should go to nearly zero. If it cannot, the model or the loss is broken, and no amount of data will fix it.
- 3.Look at what goes in: decode a batch back into text or images and look at it with the labels. Misaligned labels, wrong normalisation and empty inputs show up immediately.
- 4.Learning rate: too high and loss jumps or explodes; too low and it barely moves. Try ten times higher and lower.
- 5.Gradients: log the total gradient norm. Zero means something is detached or not trainable; enormous means instability, fixed with clipping, warmup or a lower rate.
Step 1: the loss at initialisation#
An untrained classifier should be uninformed, not wrong. Uninformed means equal probability on every class, and the cross-entropy of that is the natural log of the number of classes. This number is free, takes one forward pass, and rules out a surprising amount.
classes expected initial loss if you see instead
2 0.6931 much higher: labels or initialisation
10 2.3026 exactly 0: the model is predicting the label
65 4.1744 nan: bad init, or a loss applied to a softmax
50,257 10.8249 much lower: leakage, or the target is the inputStep 2: overfit one batch#
Take sixteen examples and train on only those for a few hundred steps. A correctly wired model must drive the loss to essentially zero, because memorising sixteen examples requires no generalisation at all. If it cannot, no quantity of data will help and the fault is in the model, the loss or the optimiser.
import torch, torch.nn as nn, torch.nn.functional as F
X = torch.randn(16, 20); Y = torch.randint(0, 5, (16,)) # labels are random noise
model = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 5))
opt = torch.optim.AdamW(model.parameters(), lr=1e-2)
for _ in range(400):
loss = F.cross_entropy(model(X), Y)
opt.zero_grad(); loss.backward(); opt.step()
print(loss.item()) # 0.00009 memorised, as it must be
# Now the classic wiring bug: only some parameters handed to the optimiser.
model = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 5))
opt = torch.optim.AdamW(model[0].parameters(), lr=1e-2) # layer 2 will never update
# ... same loop ...
print(loss.item()) # 0.00569 60x higher, and still falling. Suspicious, not obvious.Note how weak the failure signal is. The broken model still reached 0.00569, which on an unfamiliar problem reads as a success. The test is only decisive if you know what the healthy number looks like, which is why you run the healthy version first and write the number down.
Step 3: look at what actually goes in#
Decode a batch back into text or images and print it beside its label. This catches the bugs that no gradient statistic can: targets not shifted, images normalised twice, labels off by one from a category mapping, an empty string where a document should be, augmentation that has rotated the digits into different digits. It takes two minutes and it is the step people skip because it is not clever.
Step 4 and 5: learning rate and gradients#
x, y = batch(train) # one fixed batch
for step in range(300):
loss = F.cross_entropy(model(x).view(-1, V), y.view(-1))
opt.zero_grad(); loss.backward()
norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # returns the norm before clipping
opt.step()
if step % 50 == 0:
print(step, f"{loss.item():.3f}", f"grad norm {norm:.2f}")The learning rate is swept by factors of ten, not by fine adjustment: try it a hundred times smaller and a hundred times larger and see which direction improves things. Anything finer is wasted before the order of magnitude is right. A useful default to start from is 3e-4 for AdamW on a transformer and 1e-3 for smaller networks.
Gradient norms distinguish causes that the loss curve cannot. Print them per parameter rather than in total, because a total norm of 0.4 is perfectly consistent with one layer receiving nothing.
for name, p in model.named_parameters():
print(name, None if p.grad is None else round(p.grad.norm().item(), 5))
# 0.weight 0.41170 healthy
# 0.bias 0.13331
# 2.weight 0.82731
# 2.bias 0.35066
# with requires_grad = False on layer 0:
# 0.weight None <- not "small". None. It is frozen.What you see Cause Fix
p.grad is None frozen, detached, or not in the graph check requires_grad
p.grad is exactly 0.0 dead ReLU, or a saturated activation lower lr, change init
early layers tiny, late layers big vanishing gradients residuals, normalisation
norm jumps 100x on some batches a handful of extreme examples clip, then inspect them
norm healthy, loss flat the loss does not measure the task re-read the lossNaNs#
- The log of zero or a division by zero in a custom loss. Add a small epsilon, or use the built-in stable functions such as cross_entropy with logits.
- float16 overflow in mixed precision. Use bfloat16 where the hardware supports it, or a gradient scaler.
- An exploding learning rate. The loss rises for a few steps before becoming NaN; look back in the log.
- torch.autograd.set_detect_anomaly(True) reports the operation that produced the first NaN, at a large speed cost. Use it only while debugging.
The first of those is worth seeing, because it is why you are told to hand the loss raw logits rather than probabilities. A hand-written loss takes the log of a probability that is allowed to be exactly zero; the built-in one never forms the probability at all.
p = torch.tensor([0.0, 1.0])
print(torch.log(p)[0].item()) # -inf then -inf * 0 in the backward pass is nan
print(torch.log(p + 1e-8)[0].item()) # -18.42 survivable
# The built-in version works in log space throughout and never forms p at all:
print(F.cross_entropy(torch.tensor([[-1e4, 1e4]]), torch.tensor([0])).item()) # 20000.0When a NaN appears mid-run rather than at the start, it is almost always instability rather than a formula. Look back a few hundred steps in the log: an exploding run rises before it breaks, and the step where the loss first started climbing is the one to explain. If the loss was flat and then NaN in a single step, suspect a single pathological batch and print it.
if not torch.isfinite(loss):
torch.save({"x": xb, "y": yb, "step": step}, "bad_batch.pt")
raise RuntimeError(f"non-finite loss at step {step}")Check the gradients for NaN, not only the loss
A finite loss with a NaN gradient poisons every parameter on the next optimiser step, and from then on the loss is NaN with no clue where it began. Assert on the gradient norm after backward rather than on the loss after forward, because that is the earlier of the two and names the step that did it.
Where people go wrong
Changing the architecture when the loss will not fall. Almost always the bug is in the data, the loss or the learning rate.
If this comes up in an interview
Your model's training loss is flat from the first step. How do you debug it?
Check the initial loss against the expected value for uniform guessing, try to overfit one small batch, decode and inspect a batch with its labels, sweep the learning rate by factors of ten, and log gradient norms to see whether gradients reach the parameters at all.
Then they push further
It cannot overfit a single batch. What does that tell you?
The problem is in the model, the loss or the optimiser setup, not the data volume. Common causes are parameters not passed to the optimiser, a detached tensor, a wrong loss for the output format, or labels that do not match the inputs.
What gets marked up. Marked up for 'overfit one batch' as an early step and for a systematic order rather than random changes.
Build this: Take your working small transformer and deliberately introduce five bugs one at a time: unshifted targets, a detached block, a learning rate a hundred times too high, labels shuffled against inputs, and a missing zero_grad. Use the checklist to find each and note which step caught it.
Watch it explained
This is one section of PyTorch, 30 hours across 6 sections. Read it in full to get the whole topic in order.