Skip to main content

Updated Aug 15, 2026

Training Loop Anatomy

Every deep learning project, regardless of architecture or task, runs the same twenty-odd lines at its core: forward, compute loss, zero the gradients, backward, step. That order is not negotiable — get it wrong and training either does nothing or does something subtly incorrect, and both failure modes tend to fail quietly rather than crash loudly.

Key idea

Forward, loss, zero_grad, backward, step — in that order, every time, and the order is not negotiable.

The six stages of a training loop shown as a cycle: batch, forward, loss, backward, optimizer step, and zero grad
The loop every training script runs. `zero_grad()` is the step that is easiest to omit and hardest to notice missing — PyTorch accumulates gradients, so forgetting it silently sums them across batches.

The canonical loop​

for epoch in range(num_epochs):
model.train()
for x, y in train_loader:
optimizer.zero_grad()
pred = model(x)
loss = loss_fn(pred, y)
loss.backward()
optimizer.step()

Zero the gradients before the backward pass (not after) — because PyTorch Tensors and Autograd's .backward() accumulates into whatever .grad currently holds.

model.train() vs. model.eval()​

These calls don't run any computation themselves — they set a flag that changes the behaviour of specific layers. Normalization Layers's BatchNorm switches between batch statistics (train) and running statistics (eval); Regularization in Deep Nets's dropout switches between masking activations (train) and passing them through unmodified (eval). Forgetting to call .eval() before validation silently leaves both of these in training-mode behaviour during evaluation.

The validation loop and torch.no_grad()​

model.eval()
with torch.no_grad():
for x, y in val_loader:
pred = model(x)
val_loss += loss_fn(pred, y).item()

torch.no_grad() disables graph tracking for the whole block, saving memory and computation that would otherwise be wasted building a graph nothing will ever call .backward() on.

Metric accumulation done right​

Averaging per-batch metrics naively (sum(batch_metrics) / len(batches)) is subtly wrong when the final batch has fewer examples than the rest (a common consequence of drop_last=False) — the correct accumulation weights each batch's contribution by its actual example count: sum(batch_metric * batch_size) / total_examples.

Checkpointing: what to save​

A checkpoint that saves only model.state_dict() cannot correctly resume training — resuming also needs the optimizer state (momentum buffers, Adam's moment estimates), the scheduler state (where in the learning-rate schedule training was), the epoch number, and ideally the RNG state (for exact reproducibility, see Reproducibility).

Resuming correctly​

Load all of the above back in, and resume the epoch counter from where it left off — resuming from epoch 0 with a restored model but a fresh optimizer effectively discards the optimizer's accumulated momentum, causing a discontinuity in training dynamics right at the resume point.

Early stopping​

Track validation loss across epochs; if it hasn't improved for a set number of epochs (patience), stop training and restore the checkpoint from the best epoch, not the most recent one.

Gradient accumulation for large effective batch sizes​

When a desired batch size doesn't fit in GPU memory, split it into smaller "micro-batches," accumulate their gradients (call .backward() on each without an intervening zero_grad() or step()), and only call optimizer.step() once every kk micro-batches — mathematically equivalent to training with a k×k\times larger batch, at the cost of k×k\times more forward/backward passes per effective step.

Gradient clipping placement​

Clip gradients (torch.nn.utils.clip_grad_norm_) after .backward() but before optimizer.step() — clipping before the backward pass would have nothing to clip yet, and clipping after the step would be too late to affect that step's update.

Logging what matters​

At minimum: training loss per step or epoch, validation loss/metric per epoch, learning rate (to catch scheduler bugs), and gradient norms (to catch the vanishing/exploding symptoms from Vanishing and Exploding Gradients) — logging only the loss hides most of the useful diagnostic signal.

The six classic bugs​

BugSymptom
Missing zero_grad()loss decreases erratically, or the effective learning rate seems to grow over time
Forgetting model.eval() before validationvalidation metrics fluctuate oddly, especially with BatchNorm/dropout
Loss computed on the wrong axis/dimensionloss is a suspicious constant, or shapes silently broadcast incorrectly
Shuffling validation data every epochcan't directly compare validation metrics across epochs when doing error analysis
Evaluating under torch.no_grad()-less code paths meant for trainingunnecessary memory use, occasionally a crash on large validation sets
Saving only model.state_dict()resumed training discards optimizer momentum, causing a training discontinuity

Code: a complete training script with checkpointing and early stopping​

training_loop_demo.py
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader

torch.manual_seed(0)
X = torch.randn(1000, 10)
y = (X.sum(dim=1) > 0).float().unsqueeze(1)
train_ds, val_ds = TensorDataset(X[:800], y[:800]), TensorDataset(X[800:], y[800:])
train_loader = DataLoader(train_ds, batch_size=32, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=32, shuffle=False) # never shuffle validation

model = nn.Sequential(nn.Linear(10, 32), nn.ReLU(), nn.Linear(32, 1), nn.Sigmoid())
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.BCELoss()

best_val_loss, patience, patience_counter = float("inf"), 5, 0

for epoch in range(50):
model.train()
train_loss_sum, train_n = 0.0, 0
for x, y_batch in train_loader:
optimizer.zero_grad()
pred = model(x)
loss = loss_fn(pred, y_batch)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) # after backward, before step
optimizer.step()
train_loss_sum += loss.item() * len(x) # weighted by batch size
train_n += len(x)

model.eval()
val_loss_sum, val_n = 0.0, 0
with torch.no_grad():
for x, y_batch in val_loader:
pred = model(x)
val_loss_sum += loss_fn(pred, y_batch).item() * len(x)
val_n += len(x)
val_loss = val_loss_sum / val_n

if val_loss < best_val_loss:
best_val_loss, patience_counter = val_loss, 0
torch.save({
"epoch": epoch, "model_state": model.state_dict(),
"optimizer_state": optimizer.state_dict(), "val_loss": val_loss,
}, "best_checkpoint.pt")
else:
patience_counter += 1
if patience_counter >= patience:
print(f"early stopping at epoch {epoch}, best val_loss={best_val_loss:.4f}")
break

if epoch % 10 == 0:
print(f"epoch {epoch}: train_loss={train_loss_sum/train_n:.4f} val_loss={val_loss:.4f}")

See also​