Debugging PyTorch models
Shape mismatches, device and dtype errors, NaN losses, exploding gradients, autograd graph errors, and using hooks to inspect what the layers actually see.
Shape, device and dtype errors
| Error | Cause | Fix |
|---|---|---|
size mismatch for fc.weight | A different input feature count than the checkpoint expects | Print in_features and the flattened shape |
Expected all tensors to be on the same device | A tensor left on CPU, or a new layer after model.cuda() | Create modules before moving to device |
expected scalar type Long but found Float | Classification targets passed as floats | Cast to long for cross-entropy |
view size is not compatible | A hard-coded view after a shape change | Use flatten(1) instead of a fixed size |
Trying to backward through the graph a second time | A retained graph, or a second backward on the same loss | Use retain_graph=True or restructure |
one of the variables needed for gradient computation has been modified | An in-place op on a tensor needed by autograd | Clone before modifying, or use the out-of-place op |
# a shape trace that fits in a few lines and pays for itself immediately
def trace_shapes(model, sample, name="model"):
hooks = []
def make_hook(module_name):
def hook(module, inputs, output):
in_shape = tuple(inputs[0].shape) if inputs else None
out_shape = tuple(output.shape) if isinstance(output, torch.Tensor) else type(output).__name__
print(f"{module_name:45s} {str(in_shape):22s} -> {out_shape}")
return hook
for n, m in model.named_modules():
if len(list(m.children())) == 0:
hooks.append(m.register_forward_hook(make_hook(n)))
model.eval()
with torch.no_grad():
model(sample.unsqueeze(0))
for h in hooks:
h.remove()
trace_shapes(model, val_images[0])NaN, divergence and dead units
# narrow down where the NaN first appears
torch.autograd.set_detect_anomaly(True) # slow, use only while debugging
def find_first_nan(model, x):
activations = {}
def hook(name):
def fn(module, inputs, output):
if isinstance(output, torch.Tensor) and not torch.isfinite(output).all():
activations[name] = "non-finite output"
return fn
handles = [m.register_forward_hook(hook(n)) for n, m in model.named_modules()
if len(list(m.children())) == 0]
model(x)
for h in handles:
h.remove()
return activations
print(find_first_nan(model, x_bad))
# a dead ReLU detector: a unit that outputs zero for every example is gone
def dead_relu_report(model, loader, threshold=0.0):
counts, totals = {}, {}
def hook(name):
def fn(module, inputs, output):
with torch.no_grad():
alive = (output > threshold).any(dim=0).float().mean().item()
counts[name] = counts.get(name, 0) + alive
totals[name] = totals.get(name, 0) + 1
return fn
handles = [m.register_forward_hook(hook(n)) for n, m in model.named_modules()
if isinstance(m, torch.nn.ReLU)]
with torch.no_grad():
for x, _ in loader:
model(x)
for h in handles:
h.remove()
return {n: counts[n] / totals[n] for n in counts}- NaN arrives from three places: an exploding forward pass, an unstable loss (log of zero, division by zero), or an exploding gradient. Check in that order.
- Lower the learning rate by 10x as the first experiment. If the NaN disappears, it was divergence and the fix is numerical, not architectural.
- Dead ReLUs come from a learning rate that is too high or a bad initialisation. Switching to GELU or LeakyReLU often fixes it; scaling the initialisation fixes the cause.
- Wrap the loop with
torch.autograd.set_detect_anomaly(True)only while hunting. It slows training substantially and should be removed afterwards.
⚠️
A NaN loss can also come from a corrupt input row. Check the batch before blaming the model:
torch.isfinite(x).all() on every input tensor is one line and rules out an entire category of bugs.Inspecting and modifying with hooks
import torch
import torch.nn as nn
# 1. a forward hook that captures an intermediate activation
captured = {}
def capture(name):
def hook(module, inputs, output):
captured[name] = output.detach()
return hook
handle = model.layer4.register_forward_hook(capture("layer4"))
model(x)
print(captured["layer4"].shape, captured["layer4"].mean().item())
handle.remove()
# 2. a forward pre-hook that modifies the input to a layer
def clamp_inputs(module, inputs):
return (inputs[0].clamp(-10, 10),)
model.fc.register_forward_pre_hook(clamp_inputs)
# 3. a full backward hook that reports gradient statistics
def grad_stats(module, grad_input, grad_output):
g = grad_output[0]
print(module.__class__.__name__, "grad mean/std:",
round(g.mean().item(), 6), round(g.std().item(), 6))
model.fc.register_full_backward_hook(grad_stats)
# 4. gradient check for a hand-written layer
layer = CustomLayer()
x = torch.randn(4, 16, dtype=torch.double, requires_grad=True)
torch.autograd.gradcheck(layer.double(), (x,), eps=1e-6, atol=1e-4)| Tool | Answers | Note |
|---|---|---|
register_forward_hook | What did this layer output? | Returning a value replaces the output |
register_forward_pre_hook | What did this layer receive? | Returning replaces the input |
register_full_backward_hook | How large was the gradient here? | Use the full variant, not the legacy one |
torch.autograd.gradcheck | Is my backward pass correct? | Requires double precision |
torch.autograd.set_detect_anomaly | Which op produced the NaN? | Very slow; debug only |
Hooks are also the safest way to probe a model you did not write: they attach from outside the code, capture exactly the tensor you want, and can be removed without editing the architecture.
FAQ
Why does my model work in eval mode but not train mode?
A train-mode-only op is the culprit: dropout, batch-norm statistics, or a loss that behaves differently with random masks. Wrap a single step in
torch.autograd.set_detect_anomaly and compare the two modes with hooks attached.The loss is NaN only on the GPU. Why?
Reduction orders and reduced-precision kernels differ, so a numerically unstable expression that survives on the CPU produces a NaN on the GPU. Reproduce on the CPU in float64, fix the expression, then re-enable mixed precision.
Related
Building networks: layers, containers and initialisation Exporting and deploying models
Last refreshed 2026-09-18.