Deep Learning · Module 9 of 26 · 8 concepts · about 17 min

Training Neural Networks

A reliable epoch loop, an honest validation boundary, a resumable checkpoint, and the signals that tell you what went wrong.

At work: Running training jobs that finish, can be resumed after a crash, and produce a model worth shipping.

Train mode, evaluation mode and validation

What it is

An epoch runs all training batches once, but the meaningful unit of optimisation is usually the update step rather than the epoch. In training mode, each batch follows the complete loop—forward pass, loss, backward, and optimiser step—while validation measures held-out data without updating parameters. model.train() enables training behaviour such as dropout and current batch statistics; model.eval() switches layers to inference behaviour, and torch.no_grad() prevents autograd from retaining unnecessary activations. Save the checkpoint with the best validation metric rather than automatically trusting the final epoch.

Why it matters

Validation loss is the signal for early stopping, model selection, and learning-rate scheduling. Training loss alone cannot tell you whether the model is learning a reusable pattern or memorising. A widening train/validation gap is evidence to investigate capacity, augmentation, data quality, or regularisation—not a cue to simply train longer.

In the real world: Measuring a model on held-out data in the right mode, so the reported accuracy matches production.

Visual example

Deep learning

Parameter updates happen only on training batches; validation is a separate measurement.

An honest training epoch

  1. model.train()batches update parameters
  2. training lossused by backward and optimizer.step
  3. model.eval() + no_gradno updates; dropout disabled
  4. validation losschoose checkpoints and stop rules

The test set remains outside this loop until the final report.

import torch

model.train()
for xb, yb in train_loader:
    opt.zero_grad(); loss_fn(model(xb), yb).backward(); opt.step()

model.eval()
with torch.no_grad():
    val = sum(loss_fn(model(xb), yb).item() for xb, yb in val_loader) / len(val_loader)
print(val)

Validating in train mode. Dropout and batch-normalisation then alter the score, making runs noisy and comparisons unreliable.

Practise it: Batch Norm Behaves Differently at Inference

The training loop, line by line

What it is

The canonical PyTorch loop is six lines inside two nested loops: for each epoch, for each batch, move the batch to the device, zero the gradients, compute the loss from a forward pass, call backward(), then step() the optimiser (and the scheduler, if it is per-step). The order matters in exactly one place: zero_grad must come before backward, and backward before step. Everything else in a real training script — logging, checkpointing, validation, clipping, mixed precision — hangs off this skeleton without changing it.

Why it matters

PyTorch makes the loop explicit rather than hiding it behind a fit() call, which is the reason it is the research default: every intermediate value is inspectable, so any of the failures in this module can be diagnosed where it happens. The cost is that the six lines are yours to get right, and two orderings — a missing zero_grad, a step before backward — produce runs that look plausible and learn wrongly or not at all.

In the real world: The routine every model training job runs, whether it's detecting defects or ranking search results.

Visual example

Deep learning

The order is fixed in one place: zero before backward, backward before step.

One batch, six calls

  1. batch.to(device)inputs and targets on the same device as the weights
  2. opt.zero_grad()clears the previous step — not optional
  3. loss = loss_fn(model(xb), yb)forward pass builds the graph
  4. loss.backward()fills param.grad by the chain rule
  5. opt.step() then sched.step()apply the update, then advance the rate

Everything else — logging, clipping, checkpoints — wraps these five steps without reordering them.

import torch, torch.nn as nn

model, loss_fn = model.to(device), nn.CrossEntropyLoss()
opt = torch.optim.AdamW(model.parameters(), lr=3e-4)

for epoch in range(epochs):
    model.train()
    for xb, yb in train_loader:
        xb, yb = xb.to(device), yb.to(device)
        opt.zero_grad()                  # 1. clear last step's gradients
        logits = model(xb)               # 2. forward
        loss = loss_fn(logits, yb)       # 3. one scalar
        loss.backward()                  # 4. fill every .grad
        opt.step()                        # 5. apply the update
        sched.step()                      # 6. advance the schedule
    validate(model, val_loader)

Calling opt.step() before loss.backward(). The gradients from the *previous* batch are still in .grad (or are all zero on the first batch), so the model is updated with stale information — and the loss still falls, slowly, which is why this survives review.

Epochs, steps and iterations

What it is

A step (or iteration, or update) is one optimiser update: one batch forward, backward, and step(). An epoch is one complete pass over the training set, so it contains ceil(len(dataset) / batch_size) steps — which is what len(dataloader) returns. Total steps for a run is epochs × steps_per_epoch. With gradient accumulation over N micro-batches, one optimiser step spans N forward/backward passes, so steps and batches stop being the same thing.

Why it matters

Almost every training hyperparameter is defined against one of these units, and mixing them up breaks things silently. A cosine schedule with T_max in steps, stepped once per epoch, finishes its decay in the first few percent of the run. A warmup of '500' means 500 steps, not 500 epochs. And comparing two runs by epoch count is meaningless if their batch sizes differ, because the model that took eight times as many updates has done eight times as much learning per epoch.

In the real world: Planning how long a training job will take and what it will cost on the GPUs.

from torch.utils.data import DataLoader, TensorDataset

ds = TensorDataset(X, y)        # 50,000 examples
loader = DataLoader(ds, batch_size=128, shuffle=True, drop_last=False)

steps_per_epoch = len(loader)   # 391  = ceil(50000 / 128)
epochs = 10
total_steps = steps_per_epoch * epochs   # 3910 — what a scheduler needs

print(steps_per_epoch, total_steps)

Comparing 'epochs to converge' across different batch sizes. A batch of 1024 does one-eighth as many updates per epoch as a batch of 128, so the same epoch count is eight times less training — compare by step count, or by wall-clock time.

Train and validation curves: the primary diagnostic

What it is

Plot both losses against epochs on the same axes. A healthy run has both falling, with the validation curve slightly above the training one and flattening out. An overfitting run has training loss continuing to fall while validation loss bottoms out and then turns upward — the model is still improving its fit to the training set, and that improvement has stopped transferring. The epoch where validation loss turns is the checkpoint worth keeping.

Why it matters

This one plot answers 'should I train longer, add capacity, or add data?' before you spend anything. Both curves high and flat means underfitting: more capacity, a higher learning rate, or longer training. Training loss near zero with validation loss rising means overfitting: more data, augmentation, regularisation, or an earlier stop. A validation curve that is wildly noisier than the training curve usually means the validation set is too small to measure with, not that the model is unstable. The ML syllabus's bias-variance module makes the same argument across model capacities; here you watch it happen within one run.

In the real world: Reading these curves tells the team whether to stop, train longer or change the model.

Visual example

Training curves

The same model, trained too long. Training loss keeps falling; validation loss turns at epoch 8.

training lossvalidation lossvalidation, healthy run
overfitting0510152000.511.522.5epochlosscheckpoint here

Common misconception

Wrong idea: the run in the shaded region is getting better — the loss is still going down.

Correct idea: the training loss is. Validation loss has been rising since epoch 8, so every epoch after it produces a worse model on unseen data while looking like progress.

history = {"train": [], "val": []}
best_val, best_epoch = float("inf"), -1

for epoch in range(epochs):
    history["train"].append(train_one_epoch(model, train_loader, opt))
    history["val"].append(evaluate(model, val_loader))
    if history["val"][-1] < best_val:
        best_val, best_epoch = history["val"][-1], epoch
    print(epoch, round(history["train"][-1], 4), round(history["val"][-1], 4))

print(f"best validation loss {best_val:.4f} at epoch {best_epoch}")

Reading a training loss near zero as success. It means the model has memorised the training set, which is guaranteed for any sufficiently large network — the only number that carries information about unseen data is the validation one.

Practise it: Batch Norm Behaves Differently at Inference

Checkpointing: the optimizer's state, not just the weights

What it is

A resumable checkpoint is a dict: the model's state_dict, the optimizer's state_dict, the scheduler's state, the epoch or step count, and the best metric so far. Loading it means constructing the same model and optimizer objects and calling load_state_dict on each. Saving only model.state_dict() is enough to *serve* a model and not enough to *resume* one.

Why it matters

The optimizer holds real state. Adam's two moment estimates per parameter took hundreds of steps to build up, and resuming without them restarts from zero moments — which produces a visible loss spike and, with bias correction restarting too, several hundred steps of degraded updates. The scheduler's step count matters just as much: without it, a resumed run restarts its warmup or its cosine decay, so the learning rate is wrong for the rest of training. Both failures look like 'the resume made the model worse', which is exactly what happened.

In the real world: Lets a 3-day training job resume after a cloud machine is interrupted, instead of starting over.

Visual example

Checkpoints

Four things to save. Only the first is needed to serve; all four are needed to resume.

checkpoint.pt
holdsmissing it costs
modelevery weight and buffereverything — no model at all
optimizerAdam moments, momentum buffersa loss spike and hundreds of bad steps
schedulerthe step counta restarted warmup or decay; wrong lr for the rest of the run
epoch / best_valwhere the run wasa silently duplicated or skipped epoch range

The two highlighted rows are the ones people leave out, and the reason a resumed run often looks worse than the one it continued.

import torch

torch.save({
    "epoch": epoch,
    "model": model.state_dict(),
    "optimizer": opt.state_dict(),       # Adam's moments live here
    "scheduler": sched.state_dict(),     # and the step count here
    "best_val": best_val,
}, "checkpoint.pt")

ckpt = torch.load("checkpoint.pt", map_location=device)
model.load_state_dict(ckpt["model"])
opt.load_state_dict(ckpt["optimizer"])
sched.load_state_dict(ckpt["scheduler"])
start_epoch, best_val = ckpt["epoch"] + 1, ckpt["best_val"]

Saving model itself with torch.save(model, path) rather than its state_dict. The pickle embeds your class definitions and module paths, so the file stops loading the moment you refactor the code that created it.

Early stopping in a training loop

What it is

Early stopping watches the validation metric and halts when it has not improved for patience consecutive evaluations, keeping the best checkpoint rather than the last. Three parameters define it: the metric (validation loss, or the metric you actually care about), the patience (how many non-improving evaluations to tolerate), and the minimum improvement that counts as one. It is the deep-learning version of choosing model complexity, with epochs as the complexity knob.

Why it matters

Deep networks will happily continue to a training loss of zero, so 'when to stop' is a real decision rather than a technicality, and the loss curve above shows that stopping in the wrong place costs measurable accuracy. Patience matters because validation curves are noisy: patience of 1 stops on the first unlucky evaluation, while a patience of 5 to 10 evaluations rides out the noise and still catches a genuine turn. Restoring the best weights is the part people forget — stopping late and keeping the final weights recovers none of the benefit.

In the real world: Stops paying for GPU time once the model stops getting better.

import copy

best_val, best_state, waited, patience = float("inf"), None, 0, 5

for epoch in range(max_epochs):
    train_one_epoch(model, train_loader, opt)
    val = evaluate(model, val_loader)

    if val < best_val - 1e-4:                     # a real improvement
        best_val, waited = val, 0
        best_state = copy.deepcopy(model.state_dict())
    else:
        waited += 1
        if waited >= patience:
            print(f"stopping at epoch {epoch}; best was {best_val:.4f}")
            break

model.load_state_dict(best_state)                 # restore, do not keep the last

Early stopping on the test set. The stopping epoch is a hyperparameter fitted to whatever split you watch, so a test set used for stopping has been trained against and no longer estimates unseen performance.

What to monitor besides the loss

What it is

Log at least five things per step or per epoch: the training loss, the validation metric, the current learning rate (read from opt.param_groups[0]["lr"], not from your config), the global gradient norm before clipping, and a couple of actual sample predictions alongside their targets. Wall-clock time per step and peak memory are worth adding, because they are what tells you whether the data loader or the model is the bottleneck.

Why it matters

The loss is one number and most failures have a distinctive signature in the others. A schedule that never advanced shows up in the logged learning rate and nowhere else. Vanishing or exploding gradients show up in the gradient norm several hundred steps before the loss does anything visible. And a model achieving a plausible loss while predicting nonsense — the same class for everything, or a shifted-by-one sequence — is only ever caught by looking at predictions, which is why a handful of real examples in the log is worth more than another scalar.

In the real world: Tracking accuracy, learning rate and gradient size catches problems early in costly runs.

import torch

for step, (xb, yb) in enumerate(train_loader):
    opt.zero_grad()
    logits = model(xb)
    loss = loss_fn(logits, yb)
    loss.backward()
    grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    opt.step(); sched.step()

    if step % 100 == 0:
        print({
            "step": step,
            "loss": round(loss.item(), 4),
            "lr": opt.param_groups[0]["lr"],
            "grad_norm": round(grad_norm.item(), 3),
            "pred": logits[:3].argmax(dim=1).tolist(),
            "true": yb[:3].tolist(),
        })

Logging the learning rate from your config rather than from the optimizer. The config says what you intended; opt.param_groups[0]["lr"] says what the scheduler actually did, and the gap between them is one of the most common silent training bugs.

The loss went to NaN

What it is

A NaN loss means a non-finite number entered the computation and then spread: any arithmetic involving NaN produces NaN, so one bad value contaminates every parameter on the next backward pass and the run never recovers. The usual causes, in rough order of frequency: a learning rate high enough that one step overflows the weights; a log(0) or divide-by-zero from computing a loss by hand instead of using the logit-based version; NaNs or infinities already present in the input data; and mixed-precision overflow in float16 without a working gradient scaler.

Why it matters

It is unrecoverable and it is cheap to localise, so the value is in knowing the checklist rather than in bisecting for an hour. The decisive question is whether the input was already bad: check the batch for non-finite values first, because if the data is clean the problem is numerical and the learning rate is the first thing to halve. Adding a per-step finite check costs nothing and turns a silent run of NaNs into an exception at the exact step and batch that caused it.

In the real world: A run that suddenly produces “not a number” has wasted its compute; knowing the usual causes saves days.

Visual example

Debugging

A NaN checklist in the order worth checking. Rule out the data first, then halve the learning rate.

loss == nan — what to check
checkfix
1torch.isfinite(xb).all()clean or drop the offending rows; check normalisation
2learning ratehalve it; add warmup; clip gradients to 1.0
3hand-written loss with log or divideuse the *WithLogits version, or add eps
4float16 without a scaleruse torch.amp.GradScaler, or bfloat16
  1. 1Catch it at the sourceAssert the loss is finite every step, so the exception names the batch.
  2. 2Inspect that batchNon-finite inputs are the cheapest cause to rule out and the most common.
  3. 3Then suspect the step sizeIf the data is clean, halve the learning rate and re-run from a pre-NaN checkpoint.
import torch

for step, (xb, yb) in enumerate(train_loader):
    assert torch.isfinite(xb).all(), f"non-finite input at step {step}"

    opt.zero_grad()
    loss = loss_fn(model(xb), yb)
    if not torch.isfinite(loss):
        raise RuntimeError(f"loss is {loss.item()} at step {step}")

    loss.backward()
    norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    if not torch.isfinite(norm):
        raise RuntimeError(f"gradient norm is {norm} at step {step}")
    opt.step()

Restarting the run from the latest checkpoint after a NaN. If the checkpoint was written after the NaN appeared, its weights are already all NaN — the resumed run produces NaN on its first batch, which reads as a new bug rather than the same one.