Deep Learning · Module 24 of 26 · 8 concepts · about 19 min

Debugging Neural Networks

A disciplined way to find data, objective, numerical, and optimisation failures.

At work: Finding out quickly why a training run failed, before more days of GPU time are spent on it.

The overfit-one-batch test and training diagnostics

What it is

First verify that a tiny, fixed batch can be overfit: loss should approach zero and predictions should become correct. This deliberately removes generalisation, throughput, and data-diversity questions so a failure points to labels, shapes, objective, parameter registration, gradients, or update code. Once it passes, inspect inputs and labels, loss scale, gradient norms, parameter deltas, activation distributions, and train-versus-validation curves one hypothesis at a time. Log examples and intermediate outputs as well as scalar metrics; a plausible average can hide systematically broken preprocessing.

Why it matters

A large training run confounds every failure mode. A tiny controlled test separates broken code or labels from an honest capacity or generalisation problem. It also creates a regression test for the training system: when a refactor breaks the ability to overfit one batch, stop before spending hours or GPU budget on a full run.

In the real world: A 5-minute check that catches most bugs before launching a costly multi-day training run.

Visual example

Deep learning

Start with the smallest experiment that can falsify a basic assumption.

Debug in escalating scope

  1. one batchcan it overfit nearly perfectly?
  2. shapes + labelsare inputs and targets aligned?
  3. gradients + updatesare parameters actually changing?
  4. full validationdoes it generalise beyond training?

Do not tune a large run until the one-batch test passes.

import torch

def overfit_one_batch(model, batch, optimizer, steps=200, target=0.01):
    """The first test to run, and a permanent regression test afterwards."""
    x, y = batch
    model.train()
    for step in range(steps):
        optimizer.zero_grad(set_to_none=True)
        loss = loss_fn(model(x), y)
        loss.backward()
        optimizer.step()
        if loss.item() < target:
            return step, loss.item()
    raise AssertionError(f'could not overfit one batch: loss stuck at {loss.item():.4f}')

fixed = next(iter(loader))            # the SAME batch every step
overfit_one_batch(model, fixed, optimizer)

Changing architecture, optimiser, augmentation, and batch size together. When the result changes you learn nothing; isolate one variable and log the baseline.

The expected loss at initialization

What it is

Before any training, a correctly built classifier should be maximally uncertain, which gives a known cross-entropy: ln(n_classes). Ten classes should start near 2.303, two classes near 0.693, a 50,000-token vocabulary near 10.82. For regression with standardised targets, the initial MSE should be near the target variance. These are arithmetic predictions, not rules of thumb, and they can be checked in one forward pass.

Why it matters

It is the cheapest bug detector in deep learning and almost nobody runs it. An initial loss far *above* the prediction means the outputs are not near-uniform — usually a bad initialisation scale, or a double-applied activation such as a softmax before CrossEntropyLoss. An initial loss far *below* it is more alarming: the model already knows something before training, which means label information has reached the input, a target has been concatenated into the features, or the loss is being computed against the wrong tensor. Both diagnoses are available in seconds, before a single GPU-hour is spent.

In the real world: Knowing what the first loss should be quickly reveals a broken setup.

import math
import torch

@torch.no_grad()
def check_initial_loss(model, batch, n_classes, tol=0.15):
    x, y = batch
    model.eval()
    loss = loss_fn(model(x), y).item()
    expected = math.log(n_classes)
    if abs(loss - expected) > tol:
        raise AssertionError(
            f'initial loss {loss:.3f} but expected ~{expected:.3f} for '
            f'{n_classes} classes. Too high: bad init or a double softmax. '
            f'Too low: the labels are leaking into the input.'
        )
    return loss, expected

check_initial_loss(model, next(iter(loader)), n_classes=10)
# expected ~2.303 for 10 classes; ~10.82 for a 50k vocabulary

Seeing a suspiciously low initial loss and taking it as good news — 'the model is already learning'. An untrained model cannot know anything. A low initial loss is nearly always leakage or a wrong target tensor.

Reading a loss curve diagnostically

What it is

A loss curve has a small number of recognisable shapes and each points at a different cause. Flat from the start means no learning signal reaches the parameters: the learning rate is far too low, part of the graph is detached or frozen, or the optimiser was constructed over the wrong parameter list. NaN or inf means numerical instability: the learning rate is too high, or a log/divide/sqrt met zero. Violent oscillation without progress means too large a step or too small a batch. A smooth fall then a plateau is normal and is a scheduling question.

Why it matters

Treating the curve as a score rather than an instrument is what makes training feel like guesswork. Each of those shapes has a different first intervention, and they are not interchangeable: raising the learning rate fixes flat and causes NaN; lowering it fixes NaN and causes flat. Reading the shape first turns a broad search over hyperparameters into a specific hypothesis, which is the difference between an afternoon and a week. The shape also tells you *when* to look — a curve that was healthy and then diverged at step 4000 is a data or numerics event at step 4000, not a configuration problem.

In the real world: Tells the team whether to wait, stop or change something, saving GPU spend.

Visual example

Curve shapes

Four curves, four different first interventions. Raising the learning rate fixes one of them and causes another.

healthyflat: LR too low / no gradientoscillating: LR too highdiverging → NaN
0510152001234training step (hundreds)lossln(10) — the expected startNaN at step 360never settlesno gradient at all

Shape to first intervention

flat: check requires_grad, the optimizer's param list, and any stray detach(); then raise the LR

NaN: lower the LR, add grad clipping, look for log(0) / division by a computed zero

oscillating: lower the LR or raise the batch size; add warmup

healthy then plateau: not a bug - a schedule decision

The reference line is ln(10): every curve here should begin there, and one that does not has a separate bug

Why this matters

These four are nearly all of what a loss curve can look like, and each has a different fix. Knowing the shapes converts a hyperparameter search into a diagnosis.

Common misconception

Wrong idea: a bad loss curve means the learning rate needs tuning.

Correct idea: two of these four shapes are not learning-rate problems. Flat is usually a broken graph, and mid-run NaN is usually a numerical event in the data or the loss.

import math
import torch

def diagnose(losses, window=50):
    recent = losses[-window:]
    if any(math.isnan(v) or math.isinf(v) for v in recent):
        return 'NaN: lower the LR, clip gradients, check log/div by zero'
    first, last = recent[0], recent[-1]
    spread = max(recent) - min(recent)
    if abs(last - first) < 0.01 * abs(first):
        return 'flat: LR too low, or params not in the optimizer, or detached graph'
    if spread > 0.5 * abs(last):
        return 'oscillating: LR too high, or batch too small'
    return 'healthy: falling smoothly'

# The flat case is worth one explicit check:
assert any(p.requires_grad for p in model.parameters()), 'nothing is trainable'
assert sum(p.numel() for g in optimizer.param_groups for p in g['params']) > 0

Reading a flat curve as 'the task is too hard'. Flat almost always means zero gradient reaching the weights — a frozen backbone, a detach() left in from debugging, or an optimiser built before the layers were added.

Checking gradients directly

What it is

The loss is a summary and it can look reasonable while the gradients are wrong. Inspecting them directly answers three questions the loss cannot: does every parameter that should be learning actually have a .grad, are the magnitudes in a workable range, and are any of them NaN. A parameter whose gradient is None after backward() is not connected to the loss; one whose gradient norm is 1e-9 is effectively frozen; one at 1e4 will be clipped or will blow up.

Why it matters

It localises the failure to a layer, which the loss never can. A per-layer gradient-norm log is the fastest way to find a vanishing-gradient region, a detached branch, an unused head, or a normalisation layer that has collapsed. It is also the only way to confirm the optimiser is doing anything: comparing parameter values before and after step() proves the update path works end to end, which is a different claim from 'the loss went down' — a loss can fall because of a batch-norm running statistic while the weights sit still.

In the real world: Confirms every part of a model is actually learning.

Visual example

grad norms

A per-layer gradient-norm log. The loss was falling the whole time this table was recorded.

Vanishing region

(signal dies before reaching the embeddings)

No gradient at all

(grad is None - an unused head, or a detach())

p.grad.norm()

log this per layer, every few hundred steps

After loss.backward(), step 1200
grad normverdict
embed4e-9effectively frozen
block.00.00031vanishing
block.60.041ok
block.110.38ok
headNaNNOT CONNECTED

p.grad is None

the parameter is not in the loss's graph

Two broken layers, and the loss curve looked fine

What each verdict means you should do

None: find the break: a detach(), a head never used in forward, or a frozen requires_grad

~1e-9: vanishing: check init scale, normalisation placement, residual paths

nan: look one step earlier - the forward pass already produced inf

~1e4: exploding: clip, lower the LR, check for a divide by a near-zero

Why this matters

The loss is one number for the whole model. A gradient norm per layer is one number per layer, which is the granularity at which the bug actually lives.

import torch

def gradient_report(model, top=8):
    rows = []
    for name, p in model.named_parameters():
        if not p.requires_grad:
            rows.append((name, 'frozen', None))
        elif p.grad is None:
            rows.append((name, 'NOT CONNECTED to the loss', None))
        else:
            g = p.grad
            rows.append((name, 'nan' if torch.isnan(g).any() else 'ok',
                         g.norm().item()))
    return rows

# Prove the optimizer actually moves the weights.
before = {n: p.detach().clone() for n, p in model.named_parameters()}
loss.backward()
optimizer.step()
moved = [n for n, p in model.named_parameters()
         if not torch.equal(p.detach(), before[n])]
assert moved, 'loss.backward() ran but no parameter changed'

Checking gradients after optimizer.zero_grad(). With set_to_none=True they are all None and every layer looks disconnected; inspect between backward() and zero_grad().

Practise it: ReLU, Forward and Backward

Localising a shape mismatch

What it is

Shape errors are the most common class of deep-learning bug and the error message usually names the wrong place. PyTorch reports the operation that failed, which is where two incompatible shapes finally met — often several layers after the one that produced the wrong shape. The systematic fix is to make every layer boundary print its input and output shape, then read down the list for the first row that is not what you intended.

Why it matters

Reading shapes at every boundary converts a search into a scan. It also catches the far more dangerous case: a shape that is wrong but *compatible*, so nothing raises. A (32, 1) target broadcasting against a (32,) prediction silently produces a (32, 32) loss matrix and trains on nonsense; a transposed (batch, seq) versus (seq, batch) tensor runs perfectly and learns across the wrong axis. Those bugs have no error message at all, and a boundary-by-boundary shape log is how they are found.

In the real world: Pinpoints which layer breaks when inputs change, like new image sizes from a new camera.

Visual example

Shape trace

The exception names the last row. The bug is in the second.

what the shape trace printed
output shapeintended
conv1(4, 64, 112, 112)(4, 64, 112, 112)
pool(4, 64, 56, 56)(4, 64, 55, 55)
block3(4, 256, 14, 14)(4, 256, 13, 13)
flatten(4, 50176)(4, 43264)
fcRuntimeError(4, 10)
the error is reported here, but caused there
after fixing padding in pool
output shapeintended
conv1(4, 64, 112, 112)(4, 64, 112, 112)
pool(4, 64, 55, 55)(4, 64, 55, 55)
block3(4, 256, 13, 13)(4, 256, 13, 13)
flatten(4, 43264)(4, 43264)
fc(4, 10)(4, 10)

Why the report is always late

the cause: an off-by-one spatial dimension at pool, which is perfectly legal

the propagation: every later conv happily accepts 56x56 instead of 55x55

the symptom: the first layer with a FIXED input width - the dense head - finally objects

Which is why you scan the trace from the top rather than reading the traceback

Why this matters

A shape bug propagates silently through every layer that infers its input size, and only breaks at the first one that declared it. Scanning boundaries from the input finds it in one pass.

Common misconception

Wrong idea: the traceback points at the broken layer.

Correct idea: it points at the first layer that could not tolerate the break. Everything between the cause and the exception ran without complaint.

mat1 and mat2 shapes cannot be multiplied (4x50176 and 43264x10) — reported at fc, introduced at pool.

import torch
import torch.nn as nn

def trace_shapes(model, x):
    """One hook per submodule: read down the list for the first surprise."""
    handles = []

    def report(name):
        def hook(_module, inputs, output):
            shapes = tuple(tuple(i.shape) for i in inputs if torch.is_tensor(i))
            print(f'{name:<24} in={shapes} out={tuple(output.shape)}')
        return hook

    for name, module in model.named_modules():
        if not list(module.children()):        # leaves only
            handles.append(module.register_forward_hook(report(name)))
    try:
        model(x)
    finally:
        for h in handles:
            h.remove()

trace_shapes(model, torch.randn(4, 3, 224, 224))

# And assert the invariants that would otherwise broadcast silently:
assert logits.shape[0] == targets.shape[0], 'batch axis disagrees'
assert targets.ndim == 1, f'targets should be (batch,), got {targets.shape}'

Fixing a shape error with .view(-1, n) or a squeeze() until the code runs. That makes the exception disappear without making the semantics right — the tensor is now the correct shape holding the wrong arrangement of numbers.

Data bugs, model bugs and training-loop bugs

What it is

Three categories, three completely different investigations. A data bug means the tensors are wrong — misaligned labels, a broken normalisation, a corrupted decode, an augmentation applied to the image but not to the mask. A model bug means the architecture cannot express or propagate what is needed — a missing nonlinearity, a wrong mask, a bottleneck too narrow, a normalisation in the wrong place. A training-loop bug means the machinery around a correct model is wrong — a missing zero_grad, a mode left in eval, a scheduler stepping at the wrong cadence, a loss computed against the wrong tensor.

Why it matters

Separating them is what stops debugging turning into random perturbation. Each category has a distinct cheapest test. For data: look at the actual decoded examples and their labels, as images or text, not as statistics — a plausible mean hides an entirely broken preprocessing chain. For the model: does the one-batch test pass? If yes, the architecture can represent the answer and the model is not the problem. For the loop: do the parameters change, once, in the direction the gradient says? Running these three tests in order — data, model, loop — costs minutes and eliminates two thirds of the search space.

In the real world: Most “the model is bad” problems turn out to be wrong labels or shuffled data, not the model.

Visual example

Triage

Three categories, three cheapest tests. Run them in this order.

Minutes, not hours

(and eliminates the largest category)

Which kind of bug is it?
cheapest testif it passes
datalook at 10 decoded examples + labelstensors are what you think
modeloverfit one fixed batchthe architecture can express it
training loopone step moves weights down-gradientthe machinery is wired correctly

If all three pass, you have an honest capacity or generalisation problem — a different kind of work

Why this matters

Most time lost to debugging is spent changing things in the wrong category. These three tests are cheap enough to run every time and specific enough to rule a category out.

import torch

# 1. DATA: look at real examples, not summaries.
x, y = next(iter(loader))
print(x.shape, x.dtype, x.min().item(), x.max().item(), y[:8].tolist())
assert x.min() >= -3 and x.max() <= 3, 'normalisation looks wrong'
assert set(y.unique().tolist()) <= set(range(n_classes)), 'unexpected label ids'
save_grid(x[:16], y[:16], 'inspect_me.png')     # actually open this file

# 2. MODEL: can it represent the answer at all?
overfit_one_batch(model, (x, y), optimizer)      # passes -> not the model

# 3. LOOP: does one step do what it claims?
before = model.head.weight.detach().clone()
optimizer.zero_grad(set_to_none=True)
loss_fn(model(x), y).backward()
grad = model.head.weight.grad.detach().clone()
optimizer.step()
delta = model.head.weight.detach() - before
assert (delta * grad).sum() < 0, 'the update did not follow the negative gradient'

Debugging with summary statistics only. A mean and a standard deviation are compatible with images decoded in the wrong colour order, labels shifted by one row, and text truncated mid-token — all of which are obvious the moment you look at ten actual examples.

Suspiciously perfect validation accuracy

What it is

A validation score far above what the task plausibly allows is evidence of leakage, not of success. Deep learning adds leak paths that tabular ML does not have. Splitting *after* augmentation puts rotated copies of the same training image in both sets. Computing normalisation statistics over the whole dataset lets test-set means into training. Frames from one video, crops from one scan, or utterances from one speaker landing on both sides of the split makes validation a memorisation test. Deduplicating text after splitting leaves near-identical documents in both.

Why it matters

The ML syllabus established leakage as a category; the deep-learning versions are worth naming separately because the mechanisms are different and the augmentation one in particular is unique to this setting. It is also more dangerous here, because the symptom is a *good* number and nothing in the training loop complains. The defence is structural: split by the correct *group* — patient, video, document, speaker — before any transformation touches the data, and fit every statistic on the training split alone. A leaked split cannot be detected from inside the run; it can only be prevented by where the split happens.

In the real world: 99.9% usually means test images leaked into training, not a breakthrough.

Visual example

Leakage

Same source image, one rotated copy on each side. Validation is now a memorisation test.

train split
sourcevariant
img_017rotate +15
img_204original
img_311flip
split after augmentation
validation split
sourcevariant
img_017rotate -15
img_204crop
img_311original
measured vs real
metricreportedhonest
val accuracy0.9910.742
conclusionship itoverfitting badly

Why this matters

The leak is invisible from inside training: loss falls, validation rises, both curves look textbook. It is only visible in *where the split happened* relative to augmentation.

Common misconception

Wrong idea: augmentation produces new examples, so splitting afterwards is fine.

Correct idea: it produces correlated copies. Splitting afterwards puts near-duplicates of the same subject on both sides, which is the definition of a leaked split.

Every source image appears on both sides. The model is being asked to recognise pictures it has already memorised.

import numpy as np

# WRONG: augment, then split. Rotations of one image land on both sides.
augmented = augment_all(images)                    # 10x the data
train, val = split(augmented, 0.8)                 # leakage

# WRONG: normalise on everything, then split.
mean, std = images.mean(), images.std()            # test rows included

# RIGHT: split by group first, then transform each side independently.
rng = np.random.default_rng(0)
patients = np.unique(patient_id)
rng.shuffle(patients)
cut = int(0.8 * len(patients))
train_ids, val_ids = set(patients[:cut]), set(patients[cut:])

train_idx = [i for i, p in enumerate(patient_id) if p in train_ids]
val_idx = [i for i, p in enumerate(patient_id) if p in val_ids]
assert not (train_ids & val_ids), 'a patient appears on both sides'

mean, std = images[train_idx].mean(), images[train_idx].std()   # train only
train = augment_all(images[train_idx])             # augment AFTER the split

Believing a group leak is impossible because the file names differ. Ten crops of one scan, or thirty frames of one video, are different files and the same subject — which is exactly the case where a 99% validation score means nothing.

Reproducibility and GPU nondeterminism

What it is

The NumPy syllabus covered seeding a generator. Deep learning on a GPU has four more sources of variation. DataLoader workers each need their seed set. cuDNN benchmarks several convolution algorithms and picks the fastest, which may differ between runs. Many CUDA kernels — scatter-adds, some backward passes, atomic reductions — accumulate in nondeterministic float order, so the same inputs give bitwise different outputs. And multi-GPU gradient all-reduce adds its own ordering.

Why it matters

It matters because 'I changed one thing and the result moved' is only informative if the baseline is stable. Without determinism you cannot tell a real 0.4% improvement from run-to-run noise, so every comparison needs multiple seeds and a variance estimate — which is more expensive than making the run deterministic in the first place. The honest position is a choice, made explicitly: full determinism for debugging and for comparisons that must be exact, accepting a real slowdown; benchmark mode for production training, accepting that results are reproducible only in distribution and reporting a mean over seeds rather than a single number.

In the real world: Getting the same result twice matters when a regulator or a customer asks how a model was built.

Visual example

Determinism

Five sources of variation. torch.manual_seed covers one of them.

What most people set

(and then trust the run)

Genuinely costly

(determinism here is a real slowdown)

What still varies after seeding
fixed bycost of fixing
python/numpy/torch RNGmanual_seed + cuda.manual_seed_allnone
DataLoader workersworker_init_fn + generatornone
cuDNN algorithmcudnn.benchmark = Falseslower convolutions
atomic kernelsuse_deterministic_algorithms(True)slower, or it raises
multi-GPU reducefixed reduction orderslower all-reduce

use_deterministic_algorithms(True)

raises on any op with no deterministic kernel - which is the point

Rows 1 and 2 are free — set them always. Rows 3 to 5 are a deliberate trade

When to pay for which

debugging: full determinism: you need the same failure twice

A/B comparison: full determinism, or 5 seeds and a variance estimate

production training: benchmark mode on, report mean over seeds

A 0.3% improvement from a single non-deterministic run is not a result

Why this matters

The gap between 'I seeded it' and 'it is reproducible' is four rows wide, and it is the reason so many reported deep-learning improvements do not survive a second run.

import os
import random
import numpy as np
import torch

def make_deterministic(seed=0):
    os.environ['CUBLAS_WORKSPACE_CONFIG'] = ':4096:8'   # required by cuBLAS
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.benchmark = False       # stop algorithm auto-tuning
    torch.backends.cudnn.deterministic = True
    torch.use_deterministic_algorithms(True)     # raises on a nondeterministic op

def seed_worker(worker_id):
    worker_seed = torch.initial_seed() % 2**32   # each worker needs its own
    np.random.seed(worker_seed)
    random.seed(worker_seed)

loader = torch.utils.data.DataLoader(
    dataset, batch_size=32, shuffle=True,
    num_workers=4, worker_init_fn=seed_worker,
    generator=torch.Generator().manual_seed(0),
)

Calling torch.manual_seed(0) and declaring the run reproducible. Worker processes, cuDNN algorithm selection and nondeterministic CUDA kernels are all still free to vary, and on a real training run they will.