The Training Loop
Model, criterion, optimizer — the three ingredients of training — and the forward-loss-backward-step dance that turns gradients into learning.
You know what a neural network computes, and you know autograd can produce gradients for free. This lesson connects the two: the training loop, the handful of lines at the heart of every PyTorch project. Once you can read and write this loop, everything from linear regression to giant language models is a variation on the same theme.
Run this lesson in Colab
The code here uses PyTorch, which doesn't run in the browser. Download the notebook and run it in Google Colab — no GPU needed yet, this lesson's models train in seconds on CPU.
Three ingredients before you train: MCO
Every training script starts by preparing three objects. A handy mnemonic: MCO — Model, Criterion, Optimizer.
M is for Model
The thing that makes predictions. PyTorch's nn module gives you ready-made
layers; nn.Sequential chains them into a network:
import torch
from torch import nn
# 4 inputs -> hidden(3, ReLU) -> hidden(4, ReLU) -> 3 outputs
model = nn.Sequential(
nn.Linear(4, 3),
nn.ReLU(),
nn.Linear(3, 4),
nn.ReLU(),
nn.Linear(4, 3),
)
print(model)
print(model.state_dict().keys()) # every learnable weight and biasEach nn.Linear(in, out) is exactly the weighted-sum-plus-bias you built in
NumPy — a weight matrix and a bias vector, initialized randomly. The
state_dict() is the model's memory: all its learnable parameters, which is
what training will change and what you'll later save to disk.
C is for Criterion
The criterion (loss function) measures how wrong the predictions are — one number, where lower is better. You pick it based on the task:
criterion = nn.MSELoss() # regression (model ends in plain Linear)
criterion = nn.BCEWithLogitsLoss() # binary classification (1 output, raw logit)
criterion = nn.CrossEntropyLoss() # multiclass (N outputs, raw logits)Note a modern PyTorch convention: for classification the model outputs raw
logits — no sigmoid or softmax layer at the end. BCEWithLogitsLoss and
CrossEntropyLoss apply the squashing internally, which is both numerically
more stable and less code. Only add a sigmoid/softmax yourself when you
need actual probabilities at prediction time.
O is for Optimizer
The optimizer updates the weights using the gradients. It's gradient descent with an engine attached:
from torch import optim
optimizer = optim.SGD(model.parameters(), lr=0.01) # classic gradient descent
optimizer = optim.Adam(model.parameters(), lr=0.001) # adaptive — great defaultYou hand it model.parameters() — the list of tensors it's allowed to modify
— and a learning rate. You already know why the learning rate is the most
important knob; here's a refresher on the three regimes:
Gradient descent, step by step
Each step moves against the gradient: x ← x − η·∇L(x). Try η = 0.7 and watch it bounce.
Too small and training crawls; too large and the loss bounces or explodes.
Adam adapts a per-parameter step size on the fly, which makes it far more
forgiving of the initial learning-rate choice — that's why Adam (or its
sibling AdamW) with lr=0.001 is the standard starting point, while plain
SGD typically needs more tuning to shine.
The loop: forward, loss, backward, step
With MCO in place, one training step is four moves:
- Forward pass —
output = model(x): push data through the network. - Compute loss —
loss = criterion(output, y): one number measuring error. - Backward pass —
loss.backward(): autograd fills.gradfor every parameter. - Update —
optimizer.step(): nudge every weight downhill,w ← w − lr·grad.
And one bookkeeping move: optimizer.zero_grad(). Remember from the last
lesson that gradients accumulate — each backward() adds to .grad instead
of overwriting it. Without zeroing, step 2's gradients would stack on top of
step 1's, and the updates would be garbage. So every iteration clears the
gradients before (or right after) the update.
Repeat the whole dance many times. Each full pass through the training data is called an epoch.
A complete minimal example
Let's train the smallest possible network — a single nn.Linear(1, 1), i.e.
y = wx + b — to recover a line from noisy synthetic data. Every real
training script you'll ever write has this exact skeleton:
import torch
from torch import nn, optim
import matplotlib.pyplot as plt
torch.manual_seed(42)
# Synthetic data: y = 2x - 1 + noise
X = torch.rand(100, 1) * 10 # shape [100, 1]
y = 2 * X - 1 + torch.randn(100, 1) * 0.8 # shape [100, 1]
# MCO
model = nn.Linear(1, 1)
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=0.1)
# Training loop
losses = []
for epoch in range(200):
output = model(X) # 1. forward
loss = criterion(output, y) # 2. loss
optimizer.zero_grad() # clear old gradients
loss.backward() # 3. backward
optimizer.step() # 4. update
losses.append(loss.item())
if (epoch + 1) % 50 == 0:
print(f"epoch {epoch+1:3d} | loss {loss.item():.4f}")
w, b = model.weight.item(), model.bias.item()
print(f"\nlearned: y = {w:.2f}x + {b:.2f} (true: y = 2.00x - 1.00)")
plt.plot(losses)
plt.xlabel("epoch"); plt.ylabel("MSE loss")
plt.show()That's it. A 175-million-parameter network trains with the same eight lines — only the model, the data, and the number of epochs change.
loss.item(), not loss
The loss is a tensor still attached to the autograd graph. Store or print
loss.item() (a plain Python float) — appending the raw tensor to a list
keeps the whole computation graph alive and quietly eats your memory.
Reading the loss curve
The loss-per-epoch plot is your training EKG. Learn to read these shapes — they'll tell you what to fix (plotting a validation loss alongside the training loss, which we'll set up properly in the DataLoader lesson, makes the diagnosis even sharper):
- Smooth decline that flattens out at a low value — healthy. Training converged.
- Still clearly falling when the loop ends — underfitting by impatience: train longer, or raise the learning rate a bit.
- Flattens early at a high value — underfitting by capacity: the model is too simple for the pattern, or the learning rate is too small to make progress.
- Training loss keeps dropping while validation loss turns around and rises — overfitting: the model has started memorizing the training set. More data, regularization (dropout, coming soon), or early stopping.
- Spiky, oscillating, or growing — learning rate too high. The optimizer
is overshooting the valley. Cut
lrby 10x and try again.
Sensible defaults, in one place
When in doubt, start here and adjust only when the loss curve tells you to:
| Task | Model output | Criterion | Optimizer |
|---|---|---|---|
| Regression | 1 linear value per target | nn.MSELoss | Adam, lr=0.001 |
| Binary classification | 1 raw logit | nn.BCEWithLogitsLoss | Adam, lr=0.001 |
| Multiclass classification | one raw logit per class | nn.CrossEntropyLoss | Adam, lr=0.001 |
Two pairings to burn in: CrossEntropyLoss wants raw logits and integer
class labels (torch.long, not one-hot). MSELoss wants the target shaped
exactly like the output — a [100] target against a [100, 1] output will
"work" via broadcasting and silently ruin your training.
Check your understanding
Q1.In the MCO framing, what does the criterion do?
Q2.Why must the loop call optimizer.zero_grad() every iteration?
Q3.You're building a 10-class image classifier. Which output/criterion combination is correct in modern PyTorch?
Q4.The training loss oscillates wildly and sometimes jumps upward. Most likely fix?
Q5.What actually changes the model's weights?
Exercise: Break it, then fix it
Generate a new dataset y = -3x + 5 plus Gaussian noise (100 points, x in 0–10)
and train nn.Linear(1, 1) on it three times: (1) SGD with lr=0.1,
(2) SGD with lr=0.001, (3) Adam with lr=0.1 — 200 epochs each. Print the
final loss and the learned w, b for each run. Which run diverges, which
crawls, and which nails it? Relate each to the loss-curve shapes above.
Next up: nn.Module — the class-based way to define networks, plus just enough
object-oriented Python to read any PyTorch model ever written.