Deep Learning · Module 22 of 26 · 8 concepts · about 18 min

PyTorch

The tensor, module, autograd, data-loader, and checkpoint conventions used in practical training.

At work: The framework most teams use to build, train and ship deep learning models.

PyTorch tensors, modules and autograd

What it is

A PyTorch tensor carries values together with shape, dtype, device, and optionally autograd history. nn.Module registers parameters and submodules so model.parameters(), device transfers, optimisation, and checkpointing can find them; forward describes how an input tensor becomes an output tensor. Autograd records differentiable operations executed on tensors that require gradients, while Dataset and DataLoader define how individual samples are decoded, shuffled, collated, and batched. The standard training loop is deliberately explicit, which makes state and failure boundaries inspectable.

Why it matters

Most production training mistakes are state or device mistakes: an unregistered layer is not optimised, a tensor on CPU cannot combine with GPU weights, and a saved state_dict is not the same thing as arbitrary Python state. Treat shape, dtype, and device as part of every function's contract; printing only tensor values leaves out the information that usually identifies the error.

In the real world: The tools most research and production teams use to build models.

Visual example

Deep learning

A DataLoader provides batches; Module and optimizer own the learnable state.

PyTorch training objects

  1. Dataset / DataLoadersample → batch tensors
  2. nn.Moduleregistered layers and parameters
  3. autogradrecords operations and fills .grad
  4. optimizerupdates registered parameters

If a layer is not registered on the Module, the optimizer cannot train it.

import torch
import torch.nn as nn

class Net(nn.Module):
    def __init__(self, d_in, d_hidden, n_classes):
        super().__init__()
        self.body = nn.Sequential(nn.Linear(d_in, d_hidden), nn.ReLU())
        self.head = nn.Linear(d_hidden, n_classes)

    def forward(self, x):
        return self.head(self.body(x))

model = Net(64, 128, 10)
sum(p.numel() for p in model.parameters())   # every registered weight

Creating nn.Linear inside forward. It creates fresh, unregistered random weights every call, so the optimiser never repeatedly updates the same parameters.

Tensors versus NumPy ndarrays

What it is

A tensor is an ndarray with two extra properties. device says which piece of hardware holds the memory — cpu, cuda:0, mps — and operations require all their operands on the same one. requires_grad says whether autograd should record the operations that produced this tensor, building the graph that backward() will later traverse. Shape, dtype, broadcasting, axis semantics, views and strides all behave exactly as they do in NumPy.

Why it matters

Recognising how much transfers saves relearning a library. x.sum(axis=0) is x.sum(dim=0), (5,) versus (5, 1) bites identically, a slice is still a view that shares memory, and x.numpy() and torch.from_numpy(a) share the same buffer with no copy when both are on the CPU. What is genuinely new is that a tensor now has *history*: it may be a node in a graph that is keeping earlier tensors alive, which is why accumulating loss tensors in a Python list is the classic PyTorch memory leak and float(loss) or loss.item() is the fix.

In the real world: Tensors can run on GPUs and track gradients; moving data between the two is routine in pipelines.

Visual example

ndarray → tensor

Everything the NumPy syllabus taught still applies. The bottom two rows are what PyTorch adds.

The two new failure modes

(wrong device, retained graph)

Identical semantics

(axes, broadcasting, views, strides)

What a tensor carries
in NumPyin PyTorch
shape(2, 3)torch.Size([2, 3])
dtypefloat32torch.float32
device— always host RAMcpu | cuda:0 | mps
grad_fn— no such concept<AddmmBackward0>

t.detach()

same values and memory, history dropped

t.to('cuda')

a copy on another device - not in place

device and grad_fn are where PyTorch-specific bugs live

Why this matters

Framing the tensor as 'ndarray plus two fields' means the NumPy module was not a detour — it was most of this one, already done.

Common misconception

Wrong idea: a tensor is PyTorch's own array type and has to be learned from scratch.

Correct idea: it is an ndarray with a location and a history. Every shape and axis habit carries over; only those two additions are new.

import numpy as np
import torch

a = np.arange(6, dtype=np.float32).reshape(2, 3)
t = torch.from_numpy(a)          # shares memory with a, no copy
t[0, 0] = 99.0
a[0, 0]                          # 99.0 - the write went through

w = torch.zeros(3, requires_grad=True)
y = (t @ w).sum()
y.requires_grad                  # True - inherited through the graph

w.detach().requires_grad         # False - same values, no history
t.device, t.shape, t.dtype       # (device(type='cpu'), (2, 3), torch.float32)

Appending the loss tensor itself to a list for later averaging. Each entry keeps its whole computation graph — and therefore every activation in it — alive, so memory grows every step. Append loss.item().

Practise it: ReLU, Forward and Backward, Batch Norm Behaves Differently at Inference, Layer Norm, Backwards

The nn.Module contract

What it is

Subclassing nn.Module requires two things: call super().__init__() before anything else, and assign every layer and parameter as an attribute so the base class can register them. Registration is what makes model.parameters(), model.to(device), model.state_dict(), model.train() and model.eval() work — each walks the registered tree. forward defines the computation, but you never call it directly: calling model(x) invokes __call__, which runs registered forward pre-hooks, then forward, then forward hooks.

Why it matters

Two real consequences follow. First, anything not assigned as an attribute is invisible: a list of layers stored as a plain Python list is not registered, so those layers are never moved to the GPU, never optimised, and never saved — nn.ModuleList and nn.ModuleDict exist for exactly this. Second, model(x) and model.forward(x) are not interchangeable: the latter skips every hook, which silently disables anything built on them, including many profiling tools, feature extractors, quantisation observers and distributed-training wrappers.

In the real world: A standard way to package a model so it can be trained, saved and served.

Visual example

Deep learning

forward is the middle step, not the whole thing — three of the four stages are the base class's.

What model(x) actually does

  1. model(x)nn.Module.__call__, not your code
  2. forward pre-hooksdistributed sync, input casting, observers
  3. self.forward(x)the computation you wrote
  4. forward hooksactivation capture, profiling, quantisation stats

model.forward(x) runs stage 3 alone and skips the other three without warning.

import torch
import torch.nn as nn

class Broken(nn.Module):
    def __init__(self):
        super().__init__()
        self.layers = [nn.Linear(8, 8) for _ in range(3)]   # a plain list!

class Fixed(nn.Module):
    def __init__(self):
        super().__init__()
        self.layers = nn.ModuleList([nn.Linear(8, 8) for _ in range(3)])

    def forward(self, x):
        for layer in self.layers:
            x = layer(x)
        return x

sum(p.numel() for p in Broken().parameters())   # 0   - nothing registered
sum(p.numel() for p in Fixed().parameters())    # 216 - all three layers

# Hooks only fire through __call__:
handle = Fixed().register_forward_hook(lambda m, i, o: print(o.shape))

Calling model.forward(x) instead of model(x). It usually produces the right numbers, which is why it survives review — and then a profiler, a feature-extraction hook or a distributed wrapper silently does nothing.

Dataset and DataLoader

What it is

Dataset has a two-method contract: __len__ returns the number of examples and __getitem__(i) returns example i, as tensors, one at a time. DataLoader wraps it and owns everything about *batches*: how many samples per batch, whether to shuffle each epoch, how many worker processes decode in parallel, whether to pin host memory for faster transfer, and how individual samples are collated into batched tensors.

Why it matters

The separation is the design, and it is a good one. Your Dataset knows how to read one thing from disk and knows nothing about training; the DataLoader knows about throughput and knows nothing about your data format. That means shuffling, batching and parallel decoding are configuration rather than code you write, and it means the input pipeline can be made faster — more workers, pinned memory, prefetching — without touching a line of your dataset or model. It also isolates a very common performance problem: if GPU utilisation is low, the question is almost always whether the DataLoader is keeping up, and that is a question about one object.

In the real world: Feeding millions of images or records to the GPU efficiently, without running out of memory.

Visual example

Input pipeline

A clean split of responsibility: your class handles one example, the loader handles all the batching.

Your only obligation

(__len__ and __getitem__)

Where throughput lives

(num_workers, pin_memory, prefetch_factor)

Who owns what
responsibilityyou write it?
Datasetread and decode ONE exampleyes, always
DataLoaderbatch, shuffle, prefetch, pinno, configure it
Samplerwhich indices, in what orderrarely
collate_fnstack samples into a batchonly for ragged data

collate_fn=pad_batch

needed for variable-length sequences

Low GPU utilisation is almost always a row 2 problem, not a model problem

Why this matters

Because these are separate objects, an input-pipeline bottleneck can be diagnosed and fixed entirely in the loader's arguments — which is the first thing to try when the GPU is idle.

import torch
from torch.utils.data import Dataset, DataLoader

class TabularDataset(Dataset):
    def __init__(self, features, labels):
        self.features = torch.as_tensor(features, dtype=torch.float32)
        self.labels = torch.as_tensor(labels, dtype=torch.long)

    def __len__(self):
        return len(self.labels)

    def __getitem__(self, i):
        return self.features[i], self.labels[i]      # ONE example

loader = DataLoader(
    TabularDataset(X_train, y_train),
    batch_size=64,
    shuffle=True,           # reshuffled every epoch
    num_workers=4,          # four processes decoding ahead
    pin_memory=True,        # faster host-to-GPU copies
    drop_last=True,         # keeps every batch the same shape
)

for features, labels in loader:
    features.shape          # torch.Size([64, 64]) - the loader added the batch axis

Returning a whole batch from __getitem__. The DataLoader then adds a batch axis on top of yours and every downstream shape is off by one dimension — usually surfacing as a baffling error several layers into the model.

Devices and the wrong-device bug

What it is

Tensors and parameters live on a specific device, and an operation combining two of them requires both on the same one. model.to(device) moves a module's registered parameters and buffers *in place*; tensor.to(device) returns a *copy* and leaves the original alone. That asymmetry is the source of the single most common runtime error in PyTorch: a model moved to the GPU and a batch that was not, producing Expected all tensors to be on the same device.

Why it matters

It deserves its own concept because the fix is structural rather than attentive. Moving tensors at the point of use scatters .to(device) calls through the codebase and you will miss one — typically on a mask, an index tensor, a positional buffer, or a loss weight created inside forward. The robust habits are: resolve device once at startup, move every tensor a batch contains at the top of the loop, and inside a module derive new tensors from an existing one with torch.zeros_like(x) or torch.arange(n, device=x.device) so they inherit the right device automatically. Registering constants with register_buffer makes them move with model.to() for free.

In the real world: The common crash when part of a model is on the GPU and the data is still on the CPU.

Visual example

Device mismatch

The asymmetry in one picture: Module.to is in place, Tensor.to returns a copy.

after model = model.to('cuda')
objectdevice
model.head.weightcuda:0
model.pe (buffer)cuda:0
batch['x']cpu
loss_weightscpu
model.to('cuda') — but not the batch
after moving the batch too
objectdevice
model.head.weightcuda:0
model.pe (buffer)cuda:0
batch['x']cuda:0
loss_weightscuda:0

What moves, and how

parameters: moved in place by model.to(device)

buffers: moved too, if registered with register_buffer

plain attributes: a bare self.pe = torch.zeros(...) is NOT moved

batch tensors: x = x.to(device) - you must rebind the name

Why this matters

Almost every occurrence of this error is one of the last two rows: a constant that was never registered, or a .to() whose return value was thrown away.

Common misconception

Wrong idea: .to(device) moves the thing you called it on.

Correct idea: for a Module it does; for a Tensor it returns a moved copy and leaves the original where it was.

RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu!

import torch
import torch.nn as nn

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)          # in place for Modules

class Positional(nn.Module):
    def __init__(self, max_len, d):
        super().__init__()
        # A buffer is saved and moved with the model, but never optimised.
        self.register_buffer('pe', torch.zeros(max_len, d))

    def forward(self, x):
        # Derive from x, so the device is always right.
        idx = torch.arange(x.size(1), device=x.device)
        return x + self.pe[idx]

for batch in loader:
    batch = {k: v.to(device, non_blocking=True) for k, v in batch.items()}
    loss = loss_fn(model(batch['x']), batch['y'])

Writing tensor.to(device) and discarding the result. Unlike model.to(device), it is not in place — the line looks like it worked, nothing changes, and the error appears one operation later.

torch.no_grad() and model.eval()

What it is

These do two unrelated things and inference needs both. model.eval() changes *layer behaviour*: dropout stops dropping, and batch normalisation switches from batch statistics to its accumulated running statistics. torch.no_grad() changes *autograd*: operations inside it are not recorded, so no graph is built and no activations are retained for a backward pass that will never happen. Neither implies the other — eval() still builds graphs, and no_grad() still leaves dropout active.

Why it matters

Module 4 established that the forward pass is a pure evaluation and module 11 established that dropout and normalisation behave differently by mode; this is where both become one API habit. Forgetting eval() makes evaluation stochastic and irreproducible, so metrics wobble between identical runs and comparisons become unreliable. Forgetting no_grad() is not a correctness bug but a memory one: every activation is retained, so inference uses several times the memory it needs and often runs out on exactly the large batch you chose for throughput. torch.inference_mode() is a slightly stricter and faster version of no_grad() for pure serving paths.

In the real world: Two lines that make a deployed model correct and cheaper to run.

import torch

@torch.no_grad()
def evaluate(model, loader, device):
    model.eval()                     # dropout off, BatchNorm uses running stats
    total, correct, loss_sum = 0, 0, 0.0
    for x, y in loader:
        x, y = x.to(device), y.to(device)
        logits = model(x)            # no graph is built inside no_grad
        loss_sum += loss_fn(logits, y).item() * y.size(0)
        correct += (logits.argmax(1) == y).sum().item()
        total += y.size(0)
    model.train()                    # hand the model back in training mode
    return loss_sum / total, correct / total

Believing model.eval() disables gradient tracking. It does not; only no_grad() or inference_mode() does. A validation loop with eval() alone is correct and quietly uses training-sized memory.

state_dict and checkpointing

What it is

model.state_dict() returns an ordered dictionary mapping parameter and buffer names to tensors — pure data, with no reference to your classes. The standard checkpoint saves that dictionary, and loading it means constructing the model in code first and then calling load_state_dict. torch.save(model) instead pickles the whole object, which serialises a reference to your module class and therefore depends on your source layout, your package versions and your import paths at load time.

Why it matters

This is why state_dict is the recommended mechanism and not merely one option. A pickled model breaks when you rename the class, move the file, or upgrade PyTorch; a state_dict is just named tensors and keeps loading. A resumable checkpoint also needs more than the weights: the optimiser's state (AdamW's moments are as large as the model), the learning-rate scheduler's step count, the epoch, the AMP grad scaler and the RNG state. Saving weights alone produces a checkpoint that appears to resume and actually restarts the optimiser from scratch, which shows up as a loss spike at exactly the resume point.

In the real world: Saving a trained model so it can be deployed, rolled back or trained further.

Visual example

Checkpoint anatomy

A resumable checkpoint is five state dictionaries. Saving only the first is the commonest incomplete checkpoint.

Twice the model's size

(and the one people leave out)

checkpoint.pt
what it holdssizemissing it costs
modelnamed weights and buffers1x modeleverything
optimizerAdamW moments per parameter2x modela loss spike on resume
schedulerstep count for the LR curvebytesrestarts warmup
scalerAMP loss scalebytesa few unstable steps
epochwhere the run got tobyteswrong data order

torch.save(model)

pickles the class too - breaks on refactor

model.state_dict()

named tensors only - portable

Optimiser state is bigger than the model; a checkpoint without it does not resume, it restarts

Why the resume spike happens

before: AdamW has per-parameter moment estimates built up over 50k steps

resume without them: both moments reset to zero, so the first updates are effectively unscaled

symptom: loss jumps for a few hundred steps then recovers - easy to dismiss as noise

Why this matters

The loss spike at a resume boundary is one of the most frequently misdiagnosed artefacts in training. It is not the data, the seed or the hardware; it is a checkpoint missing row 2.

import torch

def save_checkpoint(path, model, optimizer, scheduler, scaler, epoch, best):
    torch.save({
        'model': model.state_dict(),
        'optimizer': optimizer.state_dict(),       # AdamW moments live here
        'scheduler': scheduler.state_dict(),       # the step count
        'scaler': scaler.state_dict(),             # mixed-precision scale
        'epoch': epoch,
        'best_val': best,
        'torch_version': torch.__version__,
    }, path)

def load_checkpoint(path, model, optimizer, scheduler, scaler):
    ckpt = torch.load(path, map_location='cpu', weights_only=False)
    model.load_state_dict(ckpt['model'])
    optimizer.load_state_dict(ckpt['optimizer'])
    scheduler.load_state_dict(ckpt['scheduler'])
    scaler.load_state_dict(ckpt['scaler'])
    return ckpt['epoch'] + 1, ckpt['best_val']

Using load_state_dict(..., strict=False) to get past a key mismatch. It silently leaves every unmatched layer randomly initialised, so the model loads without complaint and performs like it was never trained. Inspect the returned missing and unexpected key lists instead.

The three bugs that outlive every tutorial

What it is

Three mistakes account for a large share of real PyTorch debugging, and all three are mode or state errors rather than logic errors. Missing optimizer.zero_grad() lets gradients accumulate across steps, so each update is contaminated by every previous batch. Missing model.train() after a validation pass leaves dropout and normalisation in inference behaviour for the rest of training. Missing model.eval() before validation makes the metric stochastic. None of them raises.

Why it matters

They are named here, in the mechanics module, rather than left with the concepts that introduced them, because knowing *why* gradients accumulate does not stop you forgetting the call. The point of gathering them is the shared shape: each is a piece of state the loop must set at a particular moment, and each fails silently and plausibly. That makes them candidates for structure rather than discipline — put the mode switches inside the loop functions that need them, or use a single train_one_epoch / evaluate pair so there is exactly one place each call can be missing from.

In the real world: Knowing the classic mistakes saves days on every new model project.

Visual example

Silent failures

Three silent failures, one shape: state the loop forgot to set.

The nastiest one

(nothing looks wrong at any point)

What goes wrong, and what it looks like
actual effecthow it presents
no zero_gradgradients sum across all past batchesloss falls then diverges
no train()dropout stays off after validationtrains fine, generalises worse
no eval()dropout active during validationmetric differs run to run

None of the three raises an exception. All three are fixed by where the call lives, not by remembering it

Common misconception

Wrong idea: these are beginner mistakes you stop making.

Correct idea: they are structural mistakes. Experienced practitioners still make them after a refactor, which is why the mode switch belongs inside the function that depends on it.

import torch

def train_one_epoch(model, loader, optimizer, device):
    model.train()                            # set it HERE, every epoch
    for x, y in loader:
        x, y = x.to(device), y.to(device)
        optimizer.zero_grad(set_to_none=True)   # before backward, every step
        loss = loss_fn(model(x), y)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
        optimizer.step()

@torch.no_grad()
def evaluate(model, loader, device):
    model.eval()                             # and HERE, every time
    return sum(metric(model(x.to(device)), y.to(device)) for x, y in loader)

# Two functions, each owning its own mode. The bug has nowhere left to hide.

Calling model.train() once before the epoch loop. The first validation pass switches to eval() and nothing switches it back, so epochs two onward train with dropout disabled — the model trains, converges, and generalises worse for no visible reason.