Index Ai Academy
Phase 4 · Deep learning · Training

The training loop

Forward, loss, backward, step, repeat.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Five steps, over and over
  2. The optimizer does the update
  3. Worked example: the loop with one weight
  4. Training a digit classifier
  5. Evaluation mode, and why it's two calls
  6. Keep the best, not the last
  7. 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:

  1. Clear the old gradients. A gradient tells you which way to nudge a weight, and how hard.
  2. Forward pass: run the model on the data to get its predictions.
  3. Loss: measure how wrong those predictions are, as one number.
  4. Backward pass: work out the gradient for every weight.
  5. 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:

  • logits are the model's raw scores, one per class, before any softmax.
  • loss_fn is the loss function. Here it's cross-entropy, the usual choice for picking one class out of several.
  • model.train() and model.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.

Try it now

Every neural network ever trained runs this loop. Forgetting the third step is a classic bug.

Quick check
PyTorch adds each new gradient on top of the old one. What goes wrong if you leave out optimizer.zero_grad()?
  1. The forward pass stops working
  2. Gradients from earlier steps pile up, so each update uses a wrong and growing gradient
  3. The learning rate resets to zero
  4. Nothing: backward() always starts from zero
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 ww be one weight.

Let η\eta (the Greek letter eta) be the learning rate.

wnew=w−η×gradientw_{\text{new}} = w - \eta \times \text{gradient}

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 w=3w = 3, 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

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}")
It runs right here in your browser, with nothing to install.

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.

Quick check
What does this print?
w, lr, grad = 2.0, 0.1, -4.0
w = w - lr * grad
print(w)
  1. -0.4
  2. 2.4
  3. 1.6
  4. 2.0
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_split holds back 20% of the images (test_size=0.2) as the validation set. random_state=0 makes 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 history dictionary 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.

Quick check
For five validation images, logits.argmax(dim=1) gives [0, 1, 2, 3, 4]. The true labels are [0, 1, 2, 9, 9]. What accuracy does the code record?
  1. 0.6
  2. 0.75
  3. 0.4
  4. 3
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.

Quick check
You evaluate a model with dropout but forget model.eval(). What happens?
  1. PyTorch raises an error
  2. Nothing: no_grad already handles it
  3. Gradients are recorded, so the weights change
  4. Dropout stays on, so validation predictions are noisier and usually worse than the model's true ability
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.save writes 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.

Quick check
Why keep a copy of the best weights instead of the last ones?
  1. The last weights are always corrupted
  2. The optimizer deletes old weights
  3. Later epochs can overfit, so the best validation score may come earlier
  4. It makes training faster
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 seeLikely meaning
Both losses fallingstill learning; keep going
Training falls, validation risesoverfitting: stop, regularize, or get more data
Neither fallslearning rate too low, or a bug (check the loss at step 0 ≈ ln 10 for 10 classes)
Loss jumps or becomes nanlearning 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.

starting loss≈−ln⁡(110)=ln⁡10≈2.30\text{starting loss} \approx -\ln\left(\tfrac{1}{10}\right) = \ln 10 \approx 2.30

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.

Quick check
A few epochs into training, your loss prints nan. What should you try first?
  1. Lower the learning rate
  2. Train for more epochs
  3. Raise the learning rate
  4. Remove model.eval()
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

Create a free account

Try a quiz question

In which order do the five steps of a training iteration go?

  1. zero_grad, forward, loss, backward, step
  2. forward, step, loss, backward, zero_grad
  3. backward, forward, loss, step, zero_grad
  4. loss, forward, zero_grad, step, backward

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson