When the loss will not go down
Code that runs perfectly and learns nothing is the hardest PyTorch bug — but the loss value itself, and a two-line weights-moved test, narrow it down fast.
- 8 min read
- 3 reading levels
- Published
On this page 5
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
A loss that refuses to fall means the model is not learning — and because nothing crashes, the code cannot tell you why.
It feels like pushing a car with the handbrake on. You push properly. The engine of your effort is real. The car does not move, and no warning light says "handbrake" — you have to think of checking it.
Training has a handful of classic handbrakes. This lesson is the checklist for finding which one is on.
Why this is the hardest bug
Every earlier error in this section announces itself with a message naming the problem. This one is silent. The loop runs, numbers print, graphs draw — and the loss line lies flat like a hospital monitor.
The skill that fixes it is not staring harder at code. It is running small, decisive experiments, each designed to rule out one handbrake.
How it works
loss stuck?
├─ stuck at one exact value, never wobbling → weights are not being updated
├─ stuck near the "pure guessing" score → model predicts the same for everything
├─ jumping wildly up and down → steps are too big
└─ falling, then flat far above zero → learning works; the task or data is the wallEach pattern points somewhere different. Reading the shape of the stuckness is the first diagnostic.
Where you have seen this
Ask any ML engineer about their worst week and there is a fair chance the answer starts: "the model trained fine, but...". Entire debugging folklore — checklists, rituals, famous blog posts — exists for this exact silence. You are learning the distilled version.
Remember this
- No error message does not mean no bug.
- The pattern of the stuck loss — frozen, guessing-level, wild, or plateaued — is a clue.
- Diagnose with small experiments, not longer staring.
What to learn next
- Overfit a single batch first — the decisive experiment, promoted to a ritual.
- Checking that gradients reach every layer — when the two-line test says gradients are the problem.
- Finding a learning rate that works — ruling out the most common single cause.
Developer — Code and libraries.
Setup
pip install torchCaptured with torch 2.5.1 on CPU; runs are seeded and deterministic.
A perfectly broken training loop
This program contains a real bug taken from life. Read it before the output — many reviewers miss it:
import torch
import torch.nn as nn
torch.manual_seed(0)
X = torch.randn(256, 10)
y = (2 * X[:, 0] - X[:, 3] > 0).long()
model = nn.Sequential(nn.Linear(10, 32), nn.ReLU(), nn.Linear(32, 2))
optimizer = torch.optim.Adam(nn.Linear(10, 2).parameters(), lr=1e-3) # the bug: a different model's parameters
loss_fn = nn.CrossEntropyLoss()
for step in range(300):
optimizer.zero_grad()
loss = loss_fn(model(X), y)
loss.backward()
optimizer.step()
if step % 100 == 0:
print(f"step {step:3d} loss {loss.item():.4f}")
print(f"ln(2) = {torch.log(torch.tensor(2.0)).item():.4f} <- the 'predicting nothing' floor for 2 classes")step 0 loss 0.6936 step 100 loss 0.6936 step 200 loss 0.6936 ln(2) = 0.6931 <- the 'predicting nothing' floor for 2 classes
The optimiser was handed a throwaway model's parameters — a paste error, a renamed variable, a wrapped model whose inner module got passed. Gradients flow beautifully into model; the optimiser updates something else entirely. Nothing crashes, forever.
The two-line experiment that catches it
Snapshot the weights, take one training step, ask whether anything moved:
before = [p.detach().clone() for p in model.parameters()]
optimizer.zero_grad()
loss = loss_fn(model(X), y)
loss.backward()
optimizer.step()
grads_exist = all(p.grad is not None for p in model.parameters())
weights_moved = any((p != b).any() for p, b in zip(model.parameters(), before))
print(f"gradients computed: {grads_exist}")
print(f"weights changed: {weights_moved}")gradients computed: True weights changed: False
Gradients yes, movement no — the fault must sit between backward and the weights: the optimiser's parameter list, or a learning rate of zero. That is a two-suspect shortlist from a two-line test.
The magic number in the output
The stuck loss was 0.6936, and the printed floor 0.6931 — the score for predicting fifty-fifty on every sample. Every class count has such a floor: about 1.10 for 3 classes, about 2.30 for 10. (For the curious, the researcher block derives them.)
Memorise your task's floor. A loss pinned there says: the model outputs the same guess for everything. A loss stuck above it usually means something harsher — a learning rate melting the weights, or labels that do not match the features.
The full checklist, in test order
- Weights moving? Run the two-line test. If no: optimiser's parameter list,
lr=0, missingoptimizer.step(). - Loss at the guessing floor? Model ignores input: dead ReLUs from a hot learning rate, an all-zero input pipeline, logits fed through a stray softmax.
zero_gradpresent? Missing it makes gradients pile up — loss usually thrashes rather than freezes.- Learning rate sane? Sweep it with the range test; both extremes flatline in different styles.
- Can it memorise 8 samples? The decisive experiment — big enough to deserve its own lesson.
Common mistakes
Training longer, hoping. A frozen loss at step 300 is a frozen loss at step 30,000. Flat means broken machinery, not insufficient patience.
Changing five things at once. Swap the optimiser, lower the rate, add layers, change the loss — and if it improves you learned nothing about which handbrake it was. One experiment, one variable.
Confusing "not going down" with "noisy". Batch-to-batch loss jitter is normal; judge the trend across hundreds of steps, ideally smoothed, before declaring stuckness.
Blaming the model first. Architecture is the least likely suspect on this list, and the most tempting one. The boring machinery — optimiser wiring, learning rate, data dtypes — earns its place at the top of the checklist by base rate.
Try it yourself
Fix the bug (model.parameters()), confirm the loss falls, then plant each of these and watch the distinct signatures: lr=0.0, deleting optimizer.step(), and deleting optimizer.zero_grad(). Three broken runs, three recognisably different loss curves.
What to learn next
- Overfit a single batch first — the decisive experiment, promoted to a ritual.
- Checking that gradients reach every layer — when the two-line test says gradients are the problem.
- Finding a learning rate that works — ruling out the most common single cause.
Researcher — Mathematics and papers.
The guessing floor, derived
With balanced classes and a model emitting a constant distribution, cross-entropy is minimised by matching class frequencies, giving
$$\mathcal{L} = -\sum_{c=1}^{C} \pi_c \log \pi_c \;\;\xrightarrow{\;\pi_c = 1/C\;}\;\; \log C$$
- $C$ — number of classes; $\pi_c$ — the class prior.
Hence $\ln 2 \approx 0.693$, $\ln 10 \approx 2.303$. Karpathy's A Recipe for Training Neural Networks (2019) canonises "verify loss at init" — an initial loss far from $\log C$ (with imbalanced data, far from $-\sum \pi_c \log \pi_c$) indicates mis-scaled initial logits or a wrong loss configuration before a single step is taken. Setting the final-layer bias to the log-priors is the corresponding fix for imbalanced tasks.
Mechanistic causes, sorted by layer
- Optimisation wiring — disjoint parameter sets (this lesson's bug), zero lr,
step()order errors. Detectable in one step by state inspection. - Gradient pathology — vanishing signals through deep unnormalised stacks (Glorot and Bengio, 2010, Understanding the difficulty of training deep feedforward neural networks); dead ReLU collapse, where a large update pushes a unit's pre-activations permanently negative — diagnosed by activation statistics, treated by lower lr, LeakyReLU, or batch norm; severed graphs via
detach, covered in gradient flow diagnostics. - Objective mismatch — double softmax, wrong target encoding: the loss optimised is not the loss intended, and its achievable floor sits above zero. The logits lesson quantifies one such gap.
- Data pathology — shuffled label-feature alignment caps performance at the label marginal; permutation-test logic (see finding label and target bugs) separates "no signal in data" from "model cannot extract signal".
Diagnosis as hypothesis testing
Each checklist entry is a cheap experiment with high information gain — the mindset is sequential falsification rather than inspection. Two structured references: Karpathy (2019), which orders the ritual (data audit → init loss → overfit one batch → find lr → scale up), and Goodfellow, Bengio and Courville (2016), Deep Learning §11.5, on debugging strategies for learning algorithms, including comparing against known-correct implementations on known-learnable synthetic tasks — precisely what this lesson's y = f(X) construction does.
Update-to-weight ratio
A quantitative "is learning happening at a sane speed" instrument: per layer, $|\Delta\theta| / |\theta|$ per step, healthy around $10^{-3}$ (Karpathy's heuristic). Orders of magnitude lower — effectively frozen; higher — thrashing. Cheap to log, and it localises which layers are stuck, refining checklist item 1 from a boolean into a profile.
What to learn next
- Overfit a single batch first — the decisive experiment, promoted to a ritual.
- Checking that gradients reach every layer — when the two-line test says gradients are the problem.
- Finding a learning rate that works — ruling out the most common single cause.