The training loop
Forward, loss, backward, step, repeat.
Create a free account Try part of it here
Why it matters
Every neural network you'll train is trained by the same short loop. That's true of this lesson's digit reader, and it's true of a fine-tuned large language model (LLM). The loop has five lines. Bigger batches, fancier optimizers, schedules and mixed precision are all changes to those five lines. Once you know the loop by heart, you can read any training script. You'll also know where to look when something goes wrong.
What you'll be able to do
- Write the standard PyTorch training loop: zero_grad, forward, loss, backward, step
- Use an optimizer from torch.optim instead of hand-written updates
- Evaluate correctly with model.eval() and torch.no_grad()
- Track training and validation curves and keep the best checkpoint
What it covers
- Five steps, over and over
- The optimizer does the update
- Worked example: the loop with one weight
- Training a digit classifier
- Evaluation mode, and why it's two calls
- Keep the best, not the last
- Reading the curves
Five steps, over and over
Think of a cook getting a new soup recipe right. They wipe last time's notes off the board. They cook a pot. They taste it and decide how far off it is. They work out which ingredient to change, and by how much. Then they make that change and cook again. Each round, the soup gets a little better.
Training a network works the same way. Each round has five steps:
- Clear the old gradients. A gradient tells you which way to nudge a weight, and how hard.
- Forward pass: run the model on the data to get its predictions.
- Loss: measure how wrong those predictions are, as one number.
- Backward pass: work out the gradient for every weight.
- Step: nudge every weight a little in the helpful direction.
You met all five in the PyTorch autograd lesson. Here they are in the standard PyTorch form. One full round through the training data is called an epoch.
for epoch in range(num_epochs): model.train() # training-mode behavior (dropout on, batch-norm updating) optimizer.zero_grad() # 1. clear old gradients logits = model(X_train) # 2. forward pass loss = loss_fn(logits, y_train) # 3. how wrong? loss.backward() # 4. gradients for every parameter optimizer.step() # 5. update the parameters model.eval() # evaluation-mode behavior with torch.no_grad(): # no graph, less memory val_loss = loss_fn(model(X_val), y_val)
Here's what the other lines do:
logitsare the model's raw scores, one per class, before any softmax.loss_fnis the loss function. Here it's cross-entropy, the usual choice for picking one class out of several.model.train()andmodel.eval()flip the model between training and testing behavior. Only a few layer types care. Dropout randomly switches off some neurons, but only during training. Batch normalization rescales numbers using averages. You'll meet both later.- The last three lines check the model on the validation set. That's data held back from training, as in Phase 3.
torch.no_grad()tells PyTorch not to record anything for a backward pass, which saves memory.
Note: lesson code runs on the course's in-browser PyTorch subset. The code is the same as real PyTorch, but it runs on the CPU only. Its random numbers differ, so your exact losses on your own machine will differ too.
Every neural network ever trained runs this loop. Forgetting the third step is a classic bug.
Show the answer
Gradients from earlier steps pile up, so each update uses a wrong and growing gradient
Gradients accumulate in PyTorch. Without zero_grad(), step 10 would use the sum of ten steps' gradients instead of its own.
The optimizer does the update
In the autograd lesson, you updated each weight by hand with p -= lr * p.grad. An optimizer does that job for you. You hand it the model's parameters and a learning rate once. After that, each call to step() updates all of them. The digit classifier below creates one with torch.optim.SGD(model.parameters(), lr=0.5).
SGD stands for stochastic gradient descent. torch.optim.SGD uses exactly the plain rule you already know. Later lessons cover better ones.
Here's that rule. Let be one weight.
Let (the Greek letter eta) be the learning rate.
In plain words: multiply the weight's gradient by the learning rate, a small number. Subtract that from the weight. The minus sign makes the weight move downhill, toward a lower loss.
Try small numbers. Say , the gradient is 4, and the learning rate is 0.5. Then 0.5 × 4 = 2, and the new weight is 3 − 2 = 1. The optimizer does this for every weight in the model, all in one step().
Worked example: the loop with one weight
A tiny model has one weight, w. Its prediction is w × input. The input is 2 and the right answer is 6, so the perfect w is 3. Start at w = 1, use a learning rate of 0.05, and run the five steps three times.
x, y = 2.0, 6.0 # one input and its correct answer w = 1.0 # the only weight, a poor first guess lr = 0.05 # learning rate for step in range(3): grad = 0.0 # 1. clear the old gradient pred = w * x # 2. forward pass loss = (pred - y) ** 2 # 3. how wrong? (squared error) grad = 2 * (pred - y) * x # 4. gradient of the loss for w w = w - lr * grad # 5. update the weight print(f"step {step}: prediction {pred:.2f}, loss {loss:.2f}, gradient {grad:.2f}, new w {w:.2f}")
Follow step 0 by hand. The prediction is 1 × 2 = 2. It misses 6 by 4, so the squared error is 4 × 4 = 16.
The gradient is 2 × (2 − 6) × 2 = −16. In a real network, loss.backward() finds this number. Here the chain rule gives it in one line.
The update is 1 − 0.05 × (−16) = 1 + 0.8 = 1.8. The gradient was negative, so the weight went up.
Now look down the printed lines. The loss falls from 16 to 5.76 to 2.07. The weight climbs from 1 toward 3. A real network runs the same five steps, with millions of weights instead of one.
w, lr, grad = 2.0, 0.1, -4.0 w = w - lr * grad print(w)
Show the answer
2.4
0.1 × (−4) = −0.4. Subtracting −0.4 adds 0.4, so 2.0 becomes 2.4.
Training a digit classifier
Now for a real dataset. The digits dataset holds 1,797 small images of handwritten digits. Each image is 8 × 8 pixels, so it's a list of 64 numbers. Each has a label from 0 to 9, so there are 10 classes.
The network has 64 inputs, one hidden layer of 64 neurons, and 10 outputs, one per digit.
import torch import torch.nn as nn import matplotlib.pyplot as plt from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split digits = load_digits() X = torch.tensor(digits.data / 16.0, dtype=torch.float32) # pixels scaled to 0–1 y = torch.tensor(digits.target) X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=0) torch.manual_seed(0) model = nn.Sequential(nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 10)) loss_fn = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.5) history = {"train": [], "val": [], "acc": []} for epoch in range(150): model.train() optimizer.zero_grad() loss = loss_fn(model(X_train), y_train) loss.backward() optimizer.step() model.eval() with torch.no_grad(): logits = model(X_val) history["train"].append(loss.item()) history["val"].append(loss_fn(logits, y_val).item()) history["acc"].append((logits.argmax(dim=1) == y_val).float().mean().item()) if epoch % 30 == 0 or epoch == 149: print(f"epoch {epoch:>3} train loss {history['train'][-1]:.3f} val loss {history['val'][-1]:.3f} val accuracy {history['acc'][-1]:.3f}") plt.plot(history["train"], label="training loss") plt.plot(history["val"], label="validation loss") plt.xlabel("epoch") plt.legend()
Here's what the new lines do, from the top:
- The pixel values run from 0 to 16. Dividing by 16 squeezes them into 0 to 1.
train_test_splitholds back 20% of the images (test_size=0.2) as the validation set.random_state=0makes the split the same every run.torch.manual_seed(0)fixes the model's random starting weights.nn.CrossEntropyLoss()is the cross-entropy loss from Phase 2. It applies softmax for you.- The
historydictionary keeps three lists: training loss, validation loss and validation accuracy..item()turns a one-number tensor into a plain Python number. - The code prints a line every 30 epochs, plus the last one. The plot draws both loss curves.
The accuracy line needs a closer look. logits.argmax(dim=1) picks the position of the highest score in each row. That position is the model's guess. Comparing it with y_val gives True or False for each image. .float() turns those into 1s and 0s, and .mean() averages them. The result is the fraction of guesses that were right. For example, 3 right out of 4 gives 0.75.
In this lesson, each epoch is a single step on all the training data at once. The next lesson splits each epoch into many smaller steps.
Show the answer
0.6
The first three guesses match and the last two don't. That's 3 ones and 2 zeros, and their average is 3 ÷ 5 = 0.6.
Evaluation mode, and why it's two calls
Think of a sports team. In practice drills, the coach benches random players so nobody leans on one star. On game day, everyone plays. Dropout works like those drills, and model.eval() is game day.
You need two separate switches when you evaluate. They do different jobs:
model.eval()switches some layers to their testing behavior. Dropout stops switching off neurons. Batch normalization uses the averages it stored during training. It does not turn off gradients.torch.no_grad()stops PyTorch from recording steps for a backward pass. It does not change how any layer behaves.
So use both when you evaluate. Then call model.train() again before the next training step.
Forgetting either switch causes quiet problems. Forget eval() and a model with dropout gives noisy validation numbers that make it look worse than it really is. Forget train() afterwards and dropout stays off for the rest of training.
Show the answer
Dropout stays on, so validation predictions are noisier and usually worse than the model's true ability
eval() controls how layers behave. no_grad only controls gradient recording.
Keep the best, not the last
A runner remembers their personal best, not their latest time. Training should work the same way.
Validation loss usually falls for a while. Then it reaches a low point and starts to creep back up. That's the model starting to overfit: it learns quirks of the training data that don't carry over to new data.
So save a copy of the weights each time the validation loss hits a new low. A saved copy like this is called a checkpoint. At the end, load the best one back. You can also stop training once the validation loss hasn't improved for a while. That's called early stopping.
best_val, best_state = float("inf"), None for epoch in range(num_epochs): ... # train and compute val_loss if val_loss < best_val: best_val = val_loss best_state = {k: v.clone() for k, v in model.state_dict().items()} model.load_state_dict(best_state) # back to the best checkpoint torch.save(best_state, "digits.pt") # on your machine: save to disk
Here's how it works:
float("inf")is infinity. Any real loss beats it, so the first epoch always counts as the best so far.model.state_dict()is a dictionary of every weight tensor in the model, by name..clone()makes a separate copy of each tensor.model.load_state_dict(best_state)puts the saved weights back into the model.torch.savewrites them to a file. That works on your own machine, not in the browser.
Go deeper Why the copy needs .clone()
The tensors in state_dict() aren't copies. They share memory with the model's live weights. The optimizer changes those weights at every step. Without .clone(), your "best" weights would quietly change along with them. You'd end up with the last weights, not the best.
Show the answer
Later epochs can overfit, so the best validation score may come earlier
Training can keep improving on data it has seen while getting worse on new data.
Reading the curves
The two loss curves tell you how training is going. This table lists the common patterns:
| What you see | Likely meaning |
|---|---|
| Both losses falling | still learning; keep going |
| Training falls, validation rises | overfitting: stop, regularize, or get more data |
| Neither falls | learning rate too low, or a bug (check the loss at step 0 ≈ ln 10 for 10 classes) |
| Loss jumps or becomes nan | learning rate too high |
"nan" means "not a number". It's what you get when the numbers grow too big and blow up.
The third row has a handy test. Before any training, a model with 10 classes is guessing. Its loss should start near 2.30, which is ln 10. If your first loss is far from that, check your code.
Go deeper Why the starting loss is about ln 10
For one image, cross-entropy loss is minus the natural log (ln) of the probability the model gives the right answer. The loss you see is the average over all the images. You met ln in Phase 2. A model that knows nothing spreads its bets evenly. With 10 classes, that's 1/10 for each.
In plain words: an untrained model gives every class the same chance. Its loss is the minus log of that chance, so with 10 classes it starts near 2.30.
With 2 classes, each gets 1/2, and the starting loss is ln 2 ≈ 0.69. Python's math.log is ln:
import math print(round(math.log(10), 3)) # 10 classes print(round(math.log(2), 3)) # 2 classes
It prints 2.303 and 0.693. Real starting weights aren't perfectly even, so the first loss is close to these numbers rather than exactly on them.
This is the same diagnosis as Phase 3's complexity curves, now drawn over training time.
Show the answer
Lower the learning rate
nan means the numbers blew up. That usually comes from steps that are too big, so make the learning rate smaller.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
In which order do the five steps of a training iteration go?