PyTorch Model Not Learning? A Systematic Debugging Guide

Page content

PyTorch: Zero to Hero — Step 08

Your model runs.

The loss is finite.

Nothing crashes.

And it still does not learn.

This is one of the most frustrating states in machine learning because there is no stack trace telling you what is wrong.

The program is valid Python.

The tensors have legal shapes.

The GPU is busy.

The optimizer is stepping.

And the model is useless.

This post is a systematic way to debug that situation.


Why so much debugging in this series?

There is a deliberate reason this series keeps returning to debugging.

In our view, more and more of the code you write is going to be generated by an LLM.

The model can generate the nn.Module.

It can generate the training loop.

It can generate the optimizer setup, DataLoader, checkpointing code, transforms, attention blocks and evaluation loop.

And when all of that works, that is useful.

But when it does not work, you eventually reach the point where asking for another generated version of the same code stops helping.

That is where you need to get underneath the generated code.

You need to inspect:

inputs
activations
loss
computation graph
gradients
parameters
optimizer state
updates
validation behaviour

An LLM can suggest where to look.

But the debugger, the profiler, the assertions and the numbers running on your machine are the ground truth.

That is why this series contains so much debugging.

If AI writes most of the boilerplate, then understanding how to interrogate a broken system becomes more valuable, not less.


The debugging ladder

When a model is not learning, do not immediately change the architecture.

Work downward through the system.

    flowchart TD
    A[Model not learning] --> B[1. Is the data correct?]
    B -->|Yes| C[2. Are targets correct?]
    B -->|No| BFIX[Fix data pipeline]
    C -->|Yes| D[3. Do outputs look sane?]
    C -->|No| CFIX[Fix labels / alignment]
    D -->|Yes| E[4. Is the loss correct?]
    D -->|No| DFIX[Fix model output scale]
    E -->|Yes| F[5. Is autograd connected?]
    E -->|No| EFIX[Fix loss function]
    F -->|Yes| G[6. Are gradients finite and non-zero?]
    F -->|No| GFIX[Find graph break]
    G -->|Yes| H[7. Optimizer holds right params?]
    G -->|No| HFIX[Gradient problem]
    H -->|Yes| I[8. Does optimizer.step change params?]
    H -->|No| IFIX[Fix optimizer construction]
    I -->|Yes| J[9. Can model overfit tiny batch?]
    I -->|No| JFIX[Update rule broken]
    J -->|Yes| K[10. Architecture / hyperparameters]
    J -->|No| KFIX[Training system suspect]
  

That ordering matters.

A surprising amount of wasted ML debugging comes from tuning learning rates against a broken target pipeline.


A small model we can deliberately break

We will use a simple binary classification problem.

import torch
import torch.nn as nn
import torch.nn.functional as F


torch.manual_seed(42)


def make_data(n=2048):
    x = torch.randn(n, 2)
    y = ((x[:, 0] + 0.75 * x[:, 1]) > 0).long()
    return x, y


class TinyClassifier(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(2, 32),
            nn.ReLU(),
            nn.Linear(32, 32),
            nn.ReLU(),
            nn.Linear(32, 2),
        )

    def forward(self, x):
        return self.net(x)

A minimal training loop:

x, y = make_data()

model = TinyClassifier()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)

for step in range(500):
    optimizer.zero_grad(set_to_none=True)

    logits = model(x)
    loss = F.cross_entropy(logits, y)

    loss.backward()
    optimizer.step()

    if step % 50 == 0:
        accuracy = (logits.argmax(dim=-1) == y).float().mean()
        print(
            f"step={step:04d} "
            f"loss={loss.item():.4f} "
            f"acc={accuracy.item():.4f}"
        )

This should learn quickly.

Now we can use it as a controlled system for debugging.


1. Inspect the data before the model

The model cannot recover from bad inputs.

Start by printing what is actually entering the network.

def inspect_batch(x, y):
    print("x.shape:", x.shape)
    print("x.dtype:", x.dtype)
    print("x.device:", x.device)
    print("x.min:", x.min().item())
    print("x.max:", x.max().item())
    print("x.mean:", x.mean().item())
    print("x.std:", x.std().item())

    print("y.shape:", y.shape)
    print("y.dtype:", y.dtype)
    print("y.device:", y.device)
    print("y.unique:", torch.unique(y, return_counts=True))


inspect_batch(x, y)

For image data you might want:

print(images.shape)
print(images.dtype)
print(images.min().item(), images.max().item())
print(images.mean().item(), images.std().item())

For token data:

print(input_ids.shape)
print(input_ids.min().item())
print(input_ids.max().item())
print((input_ids == pad_token_id).float().mean().item())

A common failure is perfectly valid tensors containing nonsense.

Examples:

images scaled to 0..255 when the model expects 0..1
labels shifted by one class
token IDs outside the expected vocabulary mapping
all labels accidentally equal to zero
training and target arrays shuffled independently
normalization applied twice
padding dominating the sequence

The model will happily optimize against bad data.


Assert your data contract

Do not rely on visual inspection alone.

def assert_classification_batch(x, y, num_classes):
    assert x.ndim >= 2
    assert y.ndim == 1
    assert x.shape[0] == y.shape[0]

    assert torch.isfinite(x).all(), "non-finite input"

    assert y.dtype == torch.long
    assert y.min().item() >= 0
    assert y.max().item() < num_classes

Use it:

assert_classification_batch(x, y, num_classes=2)

Generated code benefits enormously from explicit contracts.

The assertion is not glamorous.

It is also much more trustworthy than assuming the generated data pipeline is correct.


2. Establish a stupid baseline

Before asking whether the neural network is good, ask whether it is better than doing nothing.

For classification:

counts = torch.bincount(y)
majority_accuracy = counts.max().float() / counts.sum()
print("majority baseline:", majority_accuracy.item())

For regression:

mean_target = y.float().mean()
baseline_mse = ((y.float() - mean_target) ** 2).mean()
print("mean baseline MSE:", baseline_mse.item())

If your network achieves 90% accuracy on a dataset where one class is 90% of the examples, you have not necessarily learned anything useful.


3. Inspect the raw model outputs

Before the loss, inspect the logits.

with torch.no_grad():
    logits = model(x[:16])

print(logits)
print("shape:", logits.shape)
print("min:", logits.min().item())
print("max:", logits.max().item())
print("mean:", logits.mean().item())
print("std:", logits.std().item())

A useful helper:

def tensor_stats(name, t):
    t = t.detach()
    print(
        f"{name:20s} "
        f"shape={tuple(t.shape)} "
        f"mean={t.float().mean().item():+.4e} "
        f"std={t.float().std().item():.4e} "
        f"min={t.min().item():+.4e} "
        f"max={t.max().item():+.4e} "
        f"finite={torch.isfinite(t).all().item()}"
    )

Then:

logits = model(x[:64])
tensor_stats("logits", logits)

Warning signs:

all outputs identical
all outputs exactly zero
magnitudes exploding immediately
NaN or Inf
one logit already enormous before training

4. Verify the loss independently

A loss function is code too.

It can be wrong.

For classification, remember that cross_entropy expects logits, not probabilities.

Correct:

logits = model(x)
loss = F.cross_entropy(logits, y)

Usually wrong:

probs = logits.softmax(dim=-1)
loss = F.cross_entropy(probs, y)

F.cross_entropy already incorporates the appropriate log-softmax operation internally.


Check one example manually

logits = model(x[:1])
target = y[:1]

loss = F.cross_entropy(logits, target)

log_probs = F.log_softmax(logits, dim=-1)
manual = -log_probs[0, target.item()]

print(loss.item())
print(manual.item())
print(torch.allclose(loss, manual))

When debugging, reducing an abstraction to one example is incredibly powerful.


5. Check whether the loss is connected to the graph

logits = model(x[:32])
loss = F.cross_entropy(logits, y[:32])

print("loss.requires_grad:", loss.requires_grad)
print("loss.grad_fn:", loss.grad_fn)

Expected:

loss.requires_grad: True
loss.grad_fn: <...>

If requires_grad is false, something broke the graph.

Common graph breaks include:

x = x.detach()
value = tensor.item()
array = tensor.cpu().numpy()
new_tensor = torch.tensor(existing_tensor)

Not all of those are wrong in general.

They are wrong if you expected gradients to flow through them.


6. Audit parameter gradients

After backward:

optimizer.zero_grad(set_to_none=True)
logits = model(x[:128])
loss = F.cross_entropy(logits, y[:128])
loss.backward()

Inspect every parameter:

def gradient_report(model):
    for name, p in model.named_parameters():
        if p.grad is None:
            print(f"{name:30s} grad=None")
            continue

        g = p.grad.detach()

        print(
            f"{name:30s} "
            f"shape={tuple(p.shape)!s:18s} "
            f"norm={g.norm().item():.4e} "
            f"mean={g.mean().item():+.4e} "
            f"max={g.abs().max().item():.4e} "
            f"finite={torch.isfinite(g).all().item()}"
        )


gradient_report(model)

This distinguishes several very different problems.

grad is None

The parameter is not connected to the loss, is frozen, or never participated in the forward path.

Gradient norm is exactly zero

The graph exists, but the local derivative may be zero.

Gradient is tiny everywhere

Possible vanishing-gradient or scale problem.

Gradient is enormous

Possible exploding-gradient problem.

Gradient contains NaN/Inf

Numerical instability has already occurred.


7. Calculate the total gradient norm

def total_grad_norm(model):
    grads = [
        p.grad.detach().norm(2)
        for p in model.parameters()
        if p.grad is not None
    ]

    if not grads:
        return torch.tensor(0.0)

    return torch.stack(grads).norm(2)


print("grad norm:", total_grad_norm(model).item())

Track it during training:

for step in range(100):
    optimizer.zero_grad(set_to_none=True)

    logits = model(x)
    loss = F.cross_entropy(logits, y)
    loss.backward()

    grad_norm = total_grad_norm(model)

    optimizer.step()

    print(
        f"step={step:03d} "
        f"loss={loss.item():.4f} "
        f"grad_norm={grad_norm.item():.4e}"
    )

The absolute value is architecture-dependent.

The pattern matters.


8. Prove that optimizer.step() changes the model

Do not assume it does.

Snapshot the parameters.

def snapshot_parameters(model):
    return {
        name: p.detach().clone()
        for name, p in model.named_parameters()
    }


def parameter_delta_report(model, before):
    for name, p in model.named_parameters():
        delta = (p.detach() - before[name]).norm().item()
        print(f"{name:30s} delta={delta:.6e}")

Use it around a single step:

optimizer.zero_grad(set_to_none=True)

logits = model(x[:128])
loss = F.cross_entropy(logits, y[:128])
loss.backward()

before = snapshot_parameters(model)
optimizer.step()

parameter_delta_report(model, before)

If every delta is zero, stop tuning the learning rate.

Your optimizer is not updating the model.


Verify optimizer membership

def optimizer_parameter_ids(optimizer):
    return {
        id(p)
        for group in optimizer.param_groups
        for p in group["params"]
    }


def report_optimizer_membership(model, optimizer):
    optimizer_ids = optimizer_parameter_ids(optimizer)

    for name, p in model.named_parameters():
        print(
            f"{name:30s} "
            f"requires_grad={str(p.requires_grad):5s} "
            f"in_optimizer={id(p) in optimizer_ids}"
        )

A parameter can have gradients and still not be in the optimizer.

That bug is particularly nasty because backward appears completely healthy.


9. The tiny-batch overfit test

This is one of the highest-value debugging techniques in deep learning.

Take a tiny number of examples.

Try to memorize them.

x_small = x[:32]
y_small = y[:32]

model = TinyClassifier()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-2)

for step in range(2000):
    optimizer.zero_grad(set_to_none=True)

    logits = model(x_small)
    loss = F.cross_entropy(logits, y_small)

    loss.backward()
    optimizer.step()

    if step % 100 == 0:
        acc = (logits.argmax(dim=-1) == y_small).float().mean()
        print(step, loss.item(), acc.item())

For a sufficiently expressive model, you should usually be able to drive training accuracy on a tiny fixed batch extremely high.

If you cannot memorize 32 examples, generalization is not your immediate problem.

The training system itself is suspect.

You can visualise the overfit test clearly:

import matplotlib.pyplot as plt

x_small = x[:32]
y_small = y[:32]
model = TinyClassifier()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-2)
losses = []

for step in range(2000):
    optimizer.zero_grad(set_to_none=True)
    logits = model(x_small)
    loss = F.cross_entropy(logits, y_small)
    loss.backward()
    optimizer.step()
    losses.append(loss.item())

plt.plot(losses)
plt.xlabel('Step')
plt.ylabel('Loss')
plt.title('Tiny-batch overfit test (should plummet)')
plt.grid(True)
plt.show()

A loss curve that refuses to drop is a loud signal that something fundamental is broken.


Why the tiny-batch test is so useful

It removes a huge number of variables.

You do not care about:

generalization
validation splits
dataset size
data-loader throughput
regularization
augmentation quality

You are asking one simpler question:

Can this model and this optimizer reduce this loss on these exact examples?

If no, debug locally.


10. Disable regularization while debugging

Regularization makes sense once the core system learns.

During debugging it can hide the signal.

Temporarily simplify:

drop dropout
remove augmentation
reduce weight decay
remove label smoothing
remove stochastic depth
remove complex schedulers

For example:

optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=1e-3,
    weight_decay=0.0,
)

The goal is not to produce the final training recipe.

The goal is to create the smallest system that should obviously learn.


11. Check train/eval mode

model.train() and model.eval() change the behavior of modules such as dropout and batch normalization.

They do not enable or disable gradients.

A clean training/evaluation structure:

model.train()

for x_batch, y_batch in train_loader:
    optimizer.zero_grad(set_to_none=True)

    logits = model(x_batch)
    loss = F.cross_entropy(logits, y_batch)

    loss.backward()
    optimizer.step()

model.eval()

with torch.no_grad():
    for x_batch, y_batch in val_loader:
        logits = model(x_batch)

Do not confuse:

model.eval()

with:

with torch.no_grad():

They solve different problems.


12. Inspect activation statistics layer by layer

A model may receive gradients and still have pathological activations.

Forward hooks are useful here.

def activation_hook(name):
    def hook(module, inputs, output):
        if not torch.is_tensor(output):
            return

        out = output.detach()

        print(
            f"{name:30s} "
            f"shape={tuple(out.shape)!s:18s} "
            f"mean={out.float().mean().item():+.3e} "
            f"std={out.float().std().item():.3e} "
            f"min={out.min().item():+.3e} "
            f"max={out.max().item():+.3e} "
            f"finite={torch.isfinite(out).all().item()}"
        )

    return hook


handles = []

for name, module in model.named_modules():
    if isinstance(module, (nn.Linear, nn.Conv2d, nn.ReLU)):
        handles.append(
            module.register_forward_hook(activation_hook(name))
        )

_ = model(x[:16])

for handle in handles:
    handle.remove()

Potential problems:

activations explode with depth
activations collapse to nearly zero
ReLUs are almost always zero
non-finite values first appear in one layer
variance grows dramatically every layer

13. Measure dead ReLUs

def zero_fraction(t):
    return (t == 0).float().mean().item()

A hook:

def relu_hook(name):
    def hook(module, inputs, output):
        print(name, "zero_fraction=", zero_fraction(output.detach()))
    return hook

If nearly every activation is zero, the downstream model receives little useful signal.


14. Detect NaN and Inf at the source

Do not merely notice that the final loss is NaN.

Find the first operation that becomes invalid.

Start with explicit checks:

def assert_finite(name, t):
    if not torch.isfinite(t).all():
        raise RuntimeError(f"{name} contains NaN or Inf")

Use them between stages:

h = model.net[0](x)
assert_finite("linear_0", h)

h = F.relu(h)
assert_finite("relu_0", h)

For difficult backward problems, PyTorch anomaly detection can provide the traceback of the forward operation associated with a failing backward computation.

with torch.autograd.detect_anomaly():
    logits = model(x)
    loss = F.cross_entropy(logits, y)
    loss.backward()

Do not leave anomaly detection enabled for normal training.

It is a debugging tool and adds overhead.


15. Common sources of NaN loss

Log of zero

Bad:

loss = -torch.log(probability)

If probability == 0, the result is infinite.

Safer patterns often work in logit space instead.


Division by zero

normalized = x / x.norm(dim=-1, keepdim=True)

Safer:

normalized = x / x.norm(dim=-1, keepdim=True).clamp_min(1e-8)

Exponentials of large values

weights = torch.exp(scores)

Use numerically stable formulations when possible.

For classification, prefer:

F.cross_entropy(logits, targets)

over implementing softmax and log manually.


Learning rate too high

One step may send parameters into an unstable region.

Track both loss and gradient norm.


16. Gradient clipping: useful, but not a diagnosis

PyTorch provides:

torch.nn.utils.clip_grad_norm_(
    model.parameters(),
    max_norm=1.0,
)

A standard pattern:

optimizer.zero_grad(set_to_none=True)

logits = model(x_batch)
loss = loss_fn(logits, y_batch)

loss.backward()

grad_norm = torch.nn.utils.clip_grad_norm_(
    model.parameters(),
    max_norm=1.0,
    error_if_nonfinite=True,
)

optimizer.step()

But clipping can conceal a deeper problem.

If the gradient norm jumps from 1 to 1e12, do not merely celebrate that clipping prevented the crash.

Find out why it happened.


17. Learning-rate sanity test

When the training system is known to work, learning rate becomes a meaningful suspect.

Try an order-of-magnitude sweep.

learning_rates = [
    1e-5,
    3e-5,
    1e-4,
    3e-4,
    1e-3,
    3e-3,
    1e-2,
]

For each, train from the same initial state for a small number of steps.

import copy

base_model = TinyClassifier()
base_state = copy.deepcopy(base_model.state_dict())

for lr in learning_rates:
    model = TinyClassifier()
    model.load_state_dict(base_state)

    optimizer = torch.optim.AdamW(model.parameters(), lr=lr)

    for _ in range(100):
        optimizer.zero_grad(set_to_none=True)
        logits = model(x)
        loss = F.cross_entropy(logits, y)
        loss.backward()
        optimizer.step()

    print(lr, loss.item())

Do not compare different random initializations and call it a learning-rate comparison.


18. Check for accidental target leakage — and accidental target destruction

Both can happen.

Leakage example:

features = torch.cat([features, target.float().unsqueeze(-1)], dim=-1)

The model appears brilliant because the answer is in the input.

Target destruction example:

x = x[perm]
# forgot: y = y[perm]

Now inputs and targets no longer correspond.

A model may converge to noise-level performance forever.


19. Verify input/target alignment explicitly

For synthetic data this is easy.

expected = ((x[:, 0] + 0.75 * x[:, 1]) > 0).long()
print((expected == y).float().mean())

For real datasets, build a debugging view that prints or visualizes a few (input, target) pairs.

This is especially important for generated data pipelines.

A syntactically correct transform can silently misalign your labels.


20. Track training and validation separately

Different patterns imply different problems.

train loss flat, val loss flat
    -> optimization/system problem

train loss falls, val loss flat
    -> generalization/distribution problem

train loss falls, val loss rises
    -> overfitting

train and val both NaN
    -> numerical instability

train accuracy high, val accuracy suspiciously high
    -> possible leakage

A simple evaluation function:

@torch.no_grad()
def evaluate(model, x, y):
    model.eval()
    logits = model(x)
    loss = F.cross_entropy(logits, y)
    accuracy = (logits.argmax(dim=-1) == y).float().mean()
    return loss.item(), accuracy.item()

21. Reproducibility is a debugging tool

When debugging, you want failures to be repeatable.

At minimum:

import random
import numpy as np
import torch

seed = 1234

random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)

if torch.cuda.is_available():
    torch.cuda.manual_seed_all(seed)

PyTorch does not guarantee identical results across every release, platform, device and execution mode.

But reducing nondeterminism on one environment can make development and regression debugging dramatically easier.


Save the failing batch

If a failure happens only occasionally, preserve the evidence.

if not torch.isfinite(loss):
    torch.save(
        {
            "x": x_batch.detach().cpu(),
            "y": y_batch.detach().cpu(),
            "model": model.state_dict(),
            "optimizer": optimizer.state_dict(),
            "step": step,
        },
        "failing_batch.pt",
    )
    raise RuntimeError("non-finite loss")

Now the failure can become a deterministic test case.

That is much better than trying to reproduce it from memory.


22. Debug mixed precision separately

Mixed precision adds another layer of state.

If training is unstable under AMP, first establish whether full precision works.

A typical CUDA mixed-precision loop looks like:

scaler = torch.amp.GradScaler("cuda")

for x_batch, y_batch in train_loader:
    optimizer.zero_grad(set_to_none=True)

    with torch.autocast(
        device_type="cuda",
        dtype=torch.float16,
    ):
        logits = model(x_batch)
        loss = F.cross_entropy(logits, y_batch)

    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()

If you need to inspect or clip the real gradients, unscale first.

scaler.scale(loss).backward()

scaler.unscale_(optimizer)

grad_norm = torch.nn.utils.clip_grad_norm_(
    model.parameters(),
    max_norm=1.0,
)

scaler.step(optimizer)
scaler.update()

Debugging scaled gradients as if they were ordinary gradients will mislead you.


23. Check whether the scheduler is destroying the learning rate

Print it.

print(optimizer.param_groups[0]["lr"])

During training:

for step in range(num_steps):
    # train...

    lr = optimizer.param_groups[0]["lr"]
    print(step, lr)

Common mistakes include:

scheduler stepped at the wrong frequency
scheduler stepped before optimizer unexpectedly
warmup never leaves near-zero learning rates
learning rate decays to almost zero too early
checkpoint restores optimizer but not scheduler state

Again: inspect the number rather than reasoning from the configuration file.


24. Audit parameter scale

def parameter_report(model):
    for name, p in model.named_parameters():
        d = p.detach()

        print(
            f"{name:30s} "
            f"norm={d.norm().item():.4e} "
            f"mean={d.mean().item():+.4e} "
            f"std={d.std().item():.4e} "
            f"max={d.abs().max().item():.4e}"
        )

Look for:

one parameter tensor exploding
weights collapsing to zero
unexpectedly enormous embeddings
bias terms dwarfing weight matrices

Parameter statistics are especially useful when comparing a healthy checkpoint with a broken one.


25. Compare two runs programmatically

Suppose one configuration works and one does not.

Do not eyeball two logs.

Record structured metrics.

history.append(
    {
        "step": step,
        "loss": loss.item(),
        "grad_norm": grad_norm.item(),
        "lr": optimizer.param_groups[0]["lr"],
        "accuracy": accuracy.item(),
    }
)

Then compare:

when did the trajectories diverge?
which metric diverged first?
did gradient norm diverge before loss?
did learning rate diverge before gradient norm?

The first divergence is usually more informative than the eventual crash.


26. A reusable training-step debugger

Here is a compact helper you can drop into a project.

class TrainingStepDebugger:
    def __init__(self, model, optimizer):
        self.model = model
        self.optimizer = optimizer

    def optimizer_ids(self):
        return {
            id(p)
            for group in self.optimizer.param_groups
            for p in group["params"]
        }

    def inspect_parameters(self):
        optimizer_ids = self.optimizer_ids()

        for name, p in self.model.named_parameters():
            grad = p.grad

            if grad is None:
                grad_norm = None
                finite = None
            else:
                grad_norm = grad.detach().norm().item()
                finite = torch.isfinite(grad).all().item()

            print(
                name,
                {
                    "shape": tuple(p.shape),
                    "requires_grad": p.requires_grad,
                    "in_optimizer": id(p) in optimizer_ids,
                    "grad_norm": grad_norm,
                    "grad_finite": finite,
                },
            )

    def snapshot(self):
        return {
            name: p.detach().clone()
            for name, p in self.model.named_parameters()
        }

    def report_updates(self, before):
        for name, p in self.model.named_parameters():
            delta = (p.detach() - before[name]).norm().item()
            print(name, "delta=", delta)

Use it:

debugger = TrainingStepDebugger(model, optimizer)

optimizer.zero_grad(set_to_none=True)

logits = model(x_batch)
loss = loss_fn(logits, y_batch)

loss.backward()

debugger.inspect_parameters()

before = debugger.snapshot()
optimizer.step()
debugger.report_updates(before)

That gives you a direct answer to:

which parameters exist?
which require gradients?
which got gradients?
are those gradients finite?
which parameters are owned by the optimizer?
which actually changed?

27. Build a fail-fast training loop

A production-ish training loop should catch corruption early.

def train_step(model, optimizer, x, y):
    model.train()
    optimizer.zero_grad(set_to_none=True)

    assert torch.isfinite(x).all()

    logits = model(x)
    assert torch.isfinite(logits).all()

    loss = F.cross_entropy(logits, y)

    if not torch.isfinite(loss):
        raise RuntimeError(f"non-finite loss: {loss.item()}")

    loss.backward()

    grad_norm = torch.nn.utils.clip_grad_norm_(
        model.parameters(),
        max_norm=10.0,
        error_if_nonfinite=True,
    )

    optimizer.step()

    return {
        "loss": loss.detach().item(),
        "grad_norm": grad_norm.detach().item(),
        "lr": optimizer.param_groups[0]["lr"],
    }

This does not guarantee correctness.

It converts several silent failures into immediate failures.

That is a major improvement.


28. The one-batch forensic script

When a generated training pipeline is behaving strangely, reduce it to one batch.

x_batch, y_batch = next(iter(train_loader))

print("=== INPUT ===")
inspect_batch(x_batch, y_batch)

print("\n=== PARAMETERS BEFORE ===")
parameter_report(model)

optimizer.zero_grad(set_to_none=True)

print("\n=== FORWARD ===")
logits = model(x_batch)
tensor_stats("logits", logits)

loss = loss_fn(logits, y_batch)
print("loss:", loss.item())
print("loss.requires_grad:", loss.requires_grad)
print("loss.grad_fn:", loss.grad_fn)

print("\n=== BACKWARD ===")
loss.backward()
gradient_report(model)

print("\n=== STEP ===")
before = snapshot_parameters(model)
optimizer.step()
parameter_delta_report(model, before)

This is the ML equivalent of reducing a failing program to the smallest reproducible example.


29. Questions to ask in order

When the model does not learn, use this list.

DATA
[ ] Are the input values sane?
[ ] Are the labels sane?
[ ] Are input and target still aligned?
[ ] Is class balance what I think it is?

FORWARD
[ ] Are output shapes correct?
[ ] Are outputs finite?
[ ] Do activations have reasonable scale?
[ ] Are activations collapsing or exploding?

LOSS
[ ] Is the loss appropriate for the task?
[ ] Am I passing logits/probabilities in the expected form?
[ ] Can I reproduce the loss manually for one example?

AUTOGRAD
[ ] Does loss.requires_grad == True?
[ ] Does loss have a grad_fn?
[ ] Do expected parameters receive gradients?
[ ] Are gradients finite?
[ ] Are gradient norms plausible?

OPTIMIZER
[ ] Are all trainable parameters in the optimizer?
[ ] Does optimizer.step() change them?
[ ] What is the actual current learning rate?
[ ] Did I replace parameters after constructing the optimizer?

TRAINING
[ ] Can the model overfit 32 examples?
[ ] Does the loss decrease without augmentation/regularization?
[ ] Does full precision work before AMP?
[ ] Can I reproduce the failure with fixed seeds?

We can turn this checklist into a decision flow:

    flowchart TD
    A[Model not learning] --> DATA
    subgraph DATA
        D1[Input values sane?] --> D2[Labels sane?] --> D3[Input/target aligned?]
    end
    DATA --> FORWARD
    subgraph FORWARD
        F1[Output shapes correct?] --> F2[Outputs finite?] --> F3[Activation scale reasonable?]
    end
    FORWARD --> LOSS
    subgraph LOSS
        L1[Loss appropriate?] --> L2[Logits/probs correct?] --> L3[Loss correct per example?]
    end
    LOSS --> AUTOGRAD
    subgraph AUTOGRAD
        A1[Loss requires_grad?] --> A2[Grad_fn exists?] --> A3[Grads present/finite/plausible?]
    end
    AUTOGRAD --> OPTIMIZER
    subgraph OPTIMIZER
        O1[Params in optimizer?] --> O2[step changes them?] --> O3[Actual LR as expected?]
    end
    OPTIMIZER --> TRAINING
    subgraph TRAINING
        T1[Overfit tiny batch?] --> T2[Learn without regularization?] --> T3[Full precision works?]
    end
    TRAINING --> FIX[Diagnose found issue]
  

Do not skip directly to architecture changes until you can answer those questions.


30. Deliberately break the model

The best way to learn this is to create failures on purpose.

Break 1: remove parameters from the optimizer

optimizer = torch.optim.AdamW(
    model.net[-1].parameters(),
    lr=1e-3,
)

What happens to the earlier layers?

Can your optimizer-membership report detect it?


Break 2: detach the hidden representation

class BrokenClassifier(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(2, 32)
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        h = F.relu(self.fc1(x))
        h = h.detach()
        return self.fc2(h)

Which parameters stop receiving gradients?


Break 3: destroy target alignment

perm = torch.randperm(len(x))
x_bad = x[perm]
y_bad = y

The code runs.

The shapes match.

The learning problem has been destroyed.


Break 4: use a ridiculous learning rate

optimizer = torch.optim.AdamW(model.parameters(), lr=10.0)

Track:

loss
gradient norm
parameter norm
first non-finite tensor

Break 5: apply softmax before cross entropy

probs = model(x).softmax(dim=-1)
loss = F.cross_entropy(probs, y)

Compare learning speed and gradients with the correct logit-based version.


31. What an LLM should do for you here

Use the LLM to accelerate the investigation.

Give it evidence.

Bad debugging prompt:

My PyTorch model does not learn. Fix it.

Better:

Training loss stays at 0.693.
The dataset is balanced.
The model can not overfit 32 examples.
The final layer has grad norm 2.1e-2.
The first layer has grad=None.
All parameters appear in model.named_parameters().
Here is the forward method and optimizer construction.
Explain the most likely graph break.

The difference is enormous.

The LLM becomes far more useful once you have turned the failure into observations.

The machine can propose hypotheses.

Your runtime decides which one is true.


32. What we are really learning

At this point the course is no longer primarily about remembering PyTorch syntax.

You already know how to write:

loss.backward()
optimizer.step()

The important skill is knowing what those lines imply about the system around them.

You should be able to ask:

What graph did backward traverse?
Which leaves received gradients?
Which parameters does the optimizer own?
Which values changed?
Where did non-finite values first appear?
Can this network memorize one batch?

That is the level where generated code becomes inspectable rather than mysterious.


A compact debugging harness

Here is a final version worth keeping around.

import torch


def debug_training_step(model, optimizer, loss_fn, x, y):
    model.train()

    print("INPUT")
    print("x", x.shape, x.dtype, x.device)
    print("y", y.shape, y.dtype, y.device)
    print("x finite", torch.isfinite(x).all().item())

    optimizer.zero_grad(set_to_none=True)

    logits = model(x)
    loss = loss_fn(logits, y)

    print("\nFORWARD")
    print("logits", logits.shape)
    print("logits finite", torch.isfinite(logits).all().item())
    print("loss", loss.item())
    print("loss requires_grad", loss.requires_grad)
    print("loss grad_fn", loss.grad_fn)

    if not torch.isfinite(loss):
        raise RuntimeError("loss is non-finite")

    loss.backward()

    optimizer_ids = {
        id(p)
        for group in optimizer.param_groups
        for p in group["params"]
    }

    print("\nGRADIENTS")

    for name, p in model.named_parameters():
        if p.grad is None:
            grad_info = "None"
        else:
            grad_info = (
                f"norm={p.grad.norm().item():.3e} "
                f"finite={torch.isfinite(p.grad).all().item()}"
            )

        print(
            name,
            "requires_grad=", p.requires_grad,
            "optimizer=", id(p) in optimizer_ids,
            "grad=", grad_info,
        )

    before = {
        name: p.detach().clone()
        for name, p in model.named_parameters()
    }

    optimizer.step()

    print("\nUPDATES")

    for name, p in model.named_parameters():
        delta = (p.detach() - before[name]).norm().item()
        print(name, f"delta={delta:.3e}")

    return loss.detach()

If you can run this against one batch, you can answer a remarkable number of “model not learning” questions in a few minutes.


Where we go next

We have now built enough understanding to ask a different question.

Suppose the model does learn.

But it is slow.

Or it runs out of GPU memory.

Or torch.compile makes it slower rather than faster.

Or mixed precision changes the result.

Or the GPU spends half its time idle.

That is Step 09:

PyTorch Performance: CUDA Memory, Profiling, Mixed Precision and torch.compile

After that, Step 10 will combine the entire series into the capstone:

Build a Small Language Model From Scratch in PyTorch.