Debugging PyTorch

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.

On this page 5
  1. Why this is the hardest bug
  2. How it works
  3. Where you have seen this
  4. Remember this
  5. What to learn next

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 wall

Each 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

Developer — Code and libraries.

Setup

bash
pip install torch

Captured 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:

stuck_loss.py
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")
Output
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:

did_weights_move.py
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}")
Output
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

  1. Weights moving? Run the two-line test. If no: optimiser's parameter list, lr=0, missing optimizer.step().
  2. 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.
  3. zero_grad present? Missing it makes gradients pile up — loss usually thrashes rather than freezes.
  4. Learning rate sane? Sweep it with the range test; both extremes flatline in different styles.
  5. 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

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