AI ENGINEER02-03 · PyTorch: an honest training loop
20/90
Sign in

Layer 02 · Deep Learning · Ch 03

PyTorch: an honest training loop

Turn tensors, a small model, a loss, and an optimiser into a readable training loop you can inspect and test.

65 min readdifficulty beginnerdepth build

The previous lesson traced one error backwards by hand. That is valuable because it tells you what a gradient means. But manually writing derivatives for every parameter stops being practical as soon as the model has more than a few calculations.

PyTorch is a Python library for numerical computation and deep learning. A tensorTensorA container for numbers arranged with one or more dimensions. A single number is a zero-dimensional tensor, a list is often one-dimensional, and a table is often two-dimensional. In PyTorch, tensors can hold data and, when needed, record operations for gradient calculation. stores its numbers; the library can record eligible calculations for automatic differentiation and provides model and optimiser building blocks. It does not choose a valid target, protect a data split, or decide that a model is safe to use.

Lesson details

Module
Deep learning
Lesson
3 · PyTorch: an honest training loop
Difficulty
Beginner
Learning time
65 minutes

Before you start

  • Forward passes, loss, gradients, and one gradient-descent update
  • Basic Python functions, loops, lists, and virtual environments

Skills you will gain

  • Describe what tensors, modules, losses, gradients, and optimisers each do in PyTorch
  • Trace the five essential calls in a training step in the correct order
  • Build a small nn.Module and train it on a deliberately tiny regression task
  • Separate training mode from evaluation mode and explain why gradients are disabled for evaluation
  • Save the model state and record enough context to make a training result inspectable

Why this matters

A training framework makes the repeated arithmetic manageable. It also makes it easy to hide mistakes behind five short method calls. A readable training loop gives every call one job: make a prediction, measure the error, clear old gradients, calculate new gradients, then update parameters.

Plain-English mental model

Step 1 — Tensors are shaped containers for numbers

A tensor is a container whose shape says how numbers are arranged. A score for one example might have shape (1,). A batch of four examples, each with three input features, has shape (4, 3): four rows and three values per row.

PyTorch can run the same tensor code on a CPU or a supported accelerator. Start on the CPU, make shapes and types clear, and move only after measurement gives you a reason. Inputs for a neural network are usually floating-point tensors. Classification targets often have a different type, depending on the loss; always read the loss function’s contract rather than guessing.

import torch

features = torch.tensor(
    [[0.0], [1.0], [2.0]],
    dtype=torch.float32,
)
targets = torch.tensor([[1.0], [3.0], [5.0]], dtype=torch.float32)

print(features.shape)
print(features.dtype)
print(targets.shape)

The expected output is:

torch.Size([3, 1])
torch.float32
torch.Size([3, 1])

The outer dimension is the example or batch dimension. Keeping it—even for one example—helps a model accept one example and many examples consistently.

Step 2 — Keep the five training jobs separate

FIG 02-03.1 · ONE BATCH, FIVE DISTINCT JOBSA PyTorch training loopA dataset feeds a DataLoader, which provides a small batch to a model. The model produces predictions, a loss compares them with targets, backward calculates gradients, and an optimiser step updates the model. A loop arrow returns to the DataLoader for the next batch. A separate evaluation path runs the model in evaluation mode without gradients.1234repeat for the next batchdatasetbatchDataLoadermodelpredictionlossbackwardexamples + targetssmall groupnn.Modulegradientsevaluation: model.eval() + no_grad()stepupdate
FIG 02-03.1— a DataLoader supplies a batch. The model predicts, loss measures error, backward calculates gradients, and step updates parameters. The loop repeats. Evaluation takes a deliberately separate path without gradients.

Here is the essential order. It is short enough to memorise, but each line has a distinct responsibility.

prediction = model(features)               # 1. forward pass
loss = loss_fn(prediction, targets)        # 2. numeric training signal

optimizer.zero_grad()                      # 3. remove old accumulated gradients
loss.backward()                            # 4. calculate fresh gradients
optimizer.step()                           # 5. update parameters

PyTorch adds gradients to a parameter’s .grad field by default. That is useful when you intentionally combine several smaller computations before an update. In the ordinary one-batch pattern, forgetting zero_grad() means a later update mixes in an earlier batch’s gradient. Clear before backward(), so the gradient you inspect belongs to the current batch.

Worked example — learn a line with an explicit model

This intentionally tiny problem has the rule target = 2 × input + 1. It is not a realistic product task. It lets us inspect the mechanics without pretending a short example proves generalisation.

nn.Linear(1, 1) is a model with one input, one output, a weight, and a bias. We set both parameters to zero so this first update is repeatable and understandable. The mean-squared-error loss averages the squared prediction errors.

PythonOne PyTorch update, with no hidden randomness
import torch
from torch import nn

features = torch.tensor([[0.0], [1.0]])
targets = torch.tensor([[1.0], [3.0]])  # target = 2 × input + 1

model = nn.Linear(1, 1)
with torch.no_grad():
    model.weight.fill_(0.0)
    model.bias.fill_(0.0)

loss_fn = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

prediction = model(features)
loss = loss_fn(prediction, targets)
optimizer.zero_grad()
loss.backward()
optimizer.step()

print('loss before:', round(loss.item(), 3))
print('weight after:', round(model.weight.item(), 3))
print('bias after:', round(model.bias.item(), 3))

Important lines

with torch.no_grad()
This setup changes parameters intentionally but should not become part of a gradient graph. It is not the usual way to initialise a model; it keeps this worked example deterministic.
optimizer = torch.optim.SGD(...)
The optimiser receives the parameters it may update and a learning rate. It does nothing until step() is called.
optimizer.zero_grad()
This clears the gradients stored from a prior backward call. In the normal pattern it comes before calculating the current batch gradient.
loss.backward()
Autograd follows the operations that produced loss and fills each trainable parameter’s gradient.
optimizer.step()
The optimiser reads those gradients and changes the model parameters according to its update rule.

The result does not jump to weight 2 and bias 1 after one batch. One small update moves in a helpful direction. Training repeats on data that represents the intended task, while validation checks whether progress also appears on protected examples.

Step 3 — A DataLoader provides batches, not validity

A DataLoaderDataLoaderA PyTorch object that iterates over a dataset and yields batches. It can batch examples, choose an order, and optionally load data using worker processes. It does not make a split valid or a dataset representative; those are separate design decisions. turns a dataset into an iterator over batches. It can group examples, shuffle a training order, and use worker processes when reading data becomes a measured bottleneck. It does not create a train/validation/test split for you, prevent leakage, make labels correct, or make shuffling appropriate for time-ordered data.

from torch.utils.data import DataLoader, TensorDataset

dataset = TensorDataset(features, targets)
train_loader = DataLoader(dataset, batch_size=2, shuffle=True)

for batch_features, batch_targets in train_loader:
    print(batch_features.shape, batch_targets.shape)

For this two-example dataset, the output shapes are always torch.Size([2, 1]) and torch.Size([2, 1]); only the row order may differ because shuffle=True changes it. Set shuffle=True only for a training split when the examples may safely be reordered. Do not casually shuffle a time sequence, grouped records, or a validation set merely because the default training pattern did.

An epochEpochOne pass through the training examples as defined by a data loader. An epoch is an accounting unit, not proof that a model has learned enough or will work on new data. means one pass through the examples yielded by a loader. It is a convenient counter, not a target by itself. Record both the training loss and a protected validation measure at each epoch so you can see divergence, overfitting, or a plateau rather than relying on a final number alone.

Step 4 — Train and evaluate deliberately differently

The training step needs gradients. Evaluation does not. During evaluation, use model.eval() and torch.no_grad().

model.eval() switches certain layers—such as dropout and batch normalisation—to their evaluation behaviour. It does not disable gradients. torch.no_grad() stops PyTorch recording gradient-tracking operations for the enclosed block. It does not switch model behaviour. In real code, use both for ordinary validation or inference.

PythonA small, inspectable training and evaluation loop
for epoch in range(20):
    model.train()
    for batch_features, batch_targets in train_loader:
        prediction = model(batch_features)
        loss = loss_fn(prediction, batch_targets)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    model.eval()
    with torch.no_grad():
        validation_prediction = model(validation_features)
        validation_loss = loss_fn(validation_prediction, validation_targets)

    print(epoch, validation_loss.item())

Keep validation examples separate before you create the loader. A clean-looking loop cannot repair a contaminated split. For a real project, make preprocessing an explicit, versioned part of the training and evaluation path; fit data-dependent transformations on training data only, then apply the frozen transformation elsewhere.

Try it — see why accumulated gradients matter

This browser exercise uses plain Python to make the accumulation rule visible. The arithmetic matches a simplified one-parameter update; PyTorch performs the same bookkeeping for tensors. Run it, then change clear_before_second_batch to False and compare the final weight.

LAB 02-03.A · SEE THE OLD GRADIENT STICK AROUND
IDLE⌘↵ to run

With clear_before_second_batch = True, the output is:

after batch 1: 0.4
gradient used for batch 2: -2.0
final weight: 0.6000000000000001

If you set it to False, the second update uses -6.0 instead: the old -4.0 has been added to the new -2.0. Gradient accumulation can be intentional, but it must be an explicit design choice with the loss scaling checked carefully.

Step 5 — Save model state, context, and evidence

A model stateModel stateThe values a model needs to reproduce its learned behaviour, including its parameters and persistent buffers. A PyTorch module exposes this information through state_dict; a usable training checkpoint commonly also records optimiser state and run metadata. is the learned parameter and persistent-buffer values needed for a module to reproduce its behaviour. In PyTorch, model.state_dict() provides that mapping. If you may resume training, store optimizer.state_dict() too; optimisers such as Adam keep internal history that affects future updates.

checkpoint = {
    'model_state': model.state_dict(),
    'optimizer_state': optimizer.state_dict(),
    'epoch': epoch,
    'validation_loss': validation_loss.item(),
    'data_version': 'support-labels-2026-07-15',
}
torch.save(checkpoint, 'checkpoint.pt')

The filename and one loss value are not enough evidence. Record the code revision, package versions, model configuration, data and split version, preprocessing version, random seeds where applicable, metrics and error slices, and the decision that determined whether this run may proceed. Never place secrets or unnecessary personal data into a checkpoint or its logs.

Real-world AI engineering example — a classifier that can be investigated

Common mistakes

Calling backward() an optimiser update.

Fix — backward() calculates and stores gradients. step() is the call that changes parameters.

Forgetting zero_grad() because loss fell on a toy run.

Fix — Use the explicit clear → backward → step pattern unless you are intentionally accumulating gradients and have checked scaling.

Using model.eval() as if it disabled gradients.

Fix — Use model.eval() for evaluation behaviour and torch.no_grad() to stop gradient tracking. They solve different problems.

Assuming DataLoader shuffle creates a sound train/validation split.

Fix — Create and audit the split first. A loader only iterates over the data you give it.

Saving only a .pt file with no context.

Fix — Record data, split, preprocessing, code, configuration, metrics, and optimiser state when resuming matters.

Treating a falling training loss as deployment evidence.

Fix — Compare protected validation and test evidence, relevant error slices, calibration, latency, cost, and the actual decision policy.

Knowledge check

Knowledge check

1Which call actually changes a model’s parameters in the usual PyTorch training loop?
2Why is optimizer.zero_grad() normally called before loss.backward()?
3What does model.eval() do by itself?
4What is a DataLoader not able to guarantee?

Practice activity

Practice · 25–35 minutes

Build one transparent training step

Goal: Run a deterministic PyTorch update, then explain the role and order of every line.

  1. Install PyTorch for your platform in a project virtual environment using the current official installation instructions.
  2. Run the worked example with its zeroed weight and bias.
  3. Print the prediction, loss, weight gradient, and bias gradient immediately after backward() but before step().
  4. Confirm that the first gradients are -3.0 for the weight and -4.0 for the bias.
  5. Call step(), then calculate the new loss without calling backward().
  6. Write one sentence distinguishing model.eval() from torch.no_grad().

Expected result: Before the step, the loss is 5.0, the gradients are -3.0 and -4.0, and the first update gives weight 0.3 and bias 0.4. You should be able to say that eval changes layer behaviour while no_grad stops graph recording.

Optional challenge: Wrap the two tensors in TensorDataset and DataLoader, then make the update loop work for one batch without changing the learning result.

Reveal solution

Solution: for predictions [0, 0] and targets [1, 3], mean squared error is (1² + 3²) / 2 = 5. The loss gradient for the weight is -3 and for the bias is -4. With learning rate 0.1, SGD takes weight = 0 - 0.1×(-3) = 0.3 and bias = 0 - 0.1×(-4) = 0.4. Evaluation should use both model.eval() and torch.no_grad(): the first selects evaluation behaviour for relevant modules; the second avoids creating a gradient graph.

Key takeaways

  • Tensors carry numeric data and their shapes make the batch and feature dimensions explicit.
  • A training batch follows a stable order: forward pass, loss, clear gradients, backward, optimiser step.
  • backward() calculates gradients and step() updates parameters; they are not interchangeable.
  • DataLoader makes batching and iteration convenient but cannot validate labels, splits, ordering, or leakage.
  • Evaluation normally needs both model.eval() and torch.no_grad().

Flashcards

Flashcards

1 of 7

Revision and interview questions

  1. Draw the PyTorch training loop for one batch. State what exists or changes after forward, loss, zero_grad, backward, and step.
  2. Explain with a two-batch example why PyTorch gradient accumulation can be useful but dangerous when accidental.
  3. Compare model.train(), model.eval(), torch.no_grad(), and optimizer.zero_grad(). What distinct concern does each address?
  4. Design the minimum data, configuration, and model-state record needed to investigate a surprising validation result.

One-page sketchnote summary

Print me · one-page revision sheet

PyTorch: an honest training loop

Main idea

PyTorch turns the repeated mechanics of learning into a small, inspectable loop. The framework handles tensor arithmetic and automatic differentiation; engineers remain responsible for the data, objective, evidence, and decision policy around it.

Core terms

  • Tensor — shaped numeric container
  • Module — model calculation + parameters
  • Batch — small group of examples
  • Epoch — one loader pass
  • Optimiser — applies gradient-based updates
  • Model state — learned values to save

One picture

A PyTorch training loopA dataset feeds a DataLoader, which provides a small batch to a model. The model produces predictions, a loss compares them with targets, backward calculates gradients, and an optimiser step updates the model. A loop arrow returns to the DataLoader for the next batch. A separate evaluation path runs the model in evaluation mode without gradients.1234repeat for the next batchdatasetbatchDataLoadermodelpredictionlossbackwardexamples + targetssmall groupnn.Modulegradientsevaluation: model.eval() + no_grad()stepupdate

Code pattern

predict → loss
clear → backward → step
eval: eval() + no_grad()

Watch out

  • Do not confuse backward with step
  • Do not forget gradient clearing
  • Do not use eval as a substitute for no_grad
  • Do not confuse batching with a valid split

Remember

  • Keep five training jobs separate
  • Inspect shapes at boundaries
  • Evaluate deliberately
  • Save context, not just weights
  • Lower training loss is not deployment evidence

Three quick questions

  1. What does backward calculate?
  2. Why clear gradients?
  3. What does the loader not know?

Lesson checklist

0 of 7 complete

Resources

Next up
Training neural networks reliably
You can now build a transparent framework training loop. Next, learn how batching, optimisation choices, regularisation, checkpoints, and learning curves make a real training run more stable and diagnosable.