S100 · PyTorch Fundamentals — Tensors, Autograd, nn.Module
The tool 90% of the DL world uses, from first principles. Learn tensors as numpy-with-a-GPU, autograd as the general-purpose backprop engine you saw in session 98, and nn.Module as the ergonomic layer stacking that turns 100 lines of numpy into 10 lines of PyTorch — without hiding what's underneath.
🎯 Rewrite the numpy MLP from session 97-98 in PyTorch using tensors, autograd, and nn.Module — and know exactly which line replaces which numpy computation.
Why this session exists
You've built an MLP + backprop + optimiser from scratch in numpy. That was for understanding. In production, you use PyTorch — because it has GPUs, distributed training, mixed precision, thousands of pretrained models, and a debugging story that numpy will never match. This session ports everything you built to PyTorch so you understand exactly what each PyTorch line is doing (spoiler: it's the numpy line you already wrote). No more magic.
- Create tensors on CPU or GPU, move them between devices, and reason about dtype (float32 vs float16 vs bfloat16).
- Use autograd — call .backward() and know exactly what's being computed under the hood.
- Define models with nn.Module — the __init__ / forward pattern that every PyTorch codebase uses.
- Write a training loop: DataLoader → model.forward → loss → backward → optimizer.step → zero_grad.
- Debug the three most common PyTorch bugs: tensor on wrong device, gradient not zeroed, and eval-mode forgotten.
Prerequisites
- S097 · MLP forward pass — you wrote this in numpy; today you rewrite it in PyTorch.
- S098 · Backpropagation — you understand what autograd is doing.
- S099 · Optimisers — you know what optimizer.step() should be doing.
(a) Intuition · 5 min
Imagine you built a bicycle from scratch — welded the frame, laced the wheels, machined the crank. Now you understand how a bicycle works. From tomorrow you ride a commercial bike. The commercial bike is more reliable, faster, and has gears. But whenever it makes a strange noise, you know exactly what it means because you built one.
That's this session. You built the MLP + backprop + optimiser in numpy. Now you use PyTorch. It's faster, GPU-capable, has autograd, and thousands of pretrained models. But every line you write maps back to something you already coded.
PyTorch = numpy + autograd + GPU + a layer library. Tensors are numpy arrays with an extra field requires_grad that turns on gradient tracking. nn.Module is a class that holds tensors as trainable parameters and defines a forward function. torch.optim wraps the optimisers we implemented.
The training loop is the same five steps you'd write in numpy: forward → loss → backward → step → zero_grad. That's the entire PyTorch mental model.
The three abstractions to internalise
- torch.Tensor — numpy array + device (cpu/cuda) + dtype + requires_grad. Same broadcasting, same indexing, same matmul.
- torch.autograd — tracks operations on requires_grad=True tensors, builds a graph, on .backward() walks the graph and computes gradients.
- torch.nn.Module — a class with __init__ (declare layers) and forward (define computation). Anything that inherits from Module is a model, layer, loss, or block.
PyTorch's history in 6 events
- 2002Torch (Lua) · CollobertFirst iteration of the framework, in Lua. Used by Facebook AI Research.
- 2015TensorFlow 1 · GoogleStatic computation graph, sessions. Dominant for 3 years.
- 2017PyTorch 0.1 · FacebookDynamic graph (‘define-by-run’). Wins researchers within 18 months for its debuggability.
- 2018PyTorch 1.0 · production readyTorchScript for deployment, ONNX export.
- 2019PyTorch overtakes TensorFlow in researchBy NeurIPS 2019, PyTorch is in the majority of accepted papers.
- 2023PyTorch 2.0 · torch.compileJIT compile via TorchDynamo — sometimes 2× speedup with one line change.
(b) Visual walkthrough · 15 min
The PyTorch training loop, always the same shape
Every PyTorch codebase you'll ever see is a variation on this diagram. Learn the pattern and every repo becomes readable.
The numpy → PyTorch mapping
What you built in S097-99
- X = np.array(...)
- W = RNG.normal(0, √(2/fan_in), (D, H))
- z = X @ W + b
- a = np.maximum(0, z)
- grads computed by hand
- for k in params: params[k] -= lr * grads[k]
What you'll use in production
- X = torch.tensor(...).to(device)
- nn.Linear(D, H) — He init built in for kaiming_normal init variant
- z = self.linear(X)
- F.relu(z) or nn.ReLU()(z)
- loss.backward() — autograd handles it
- optimizer.step() then optimizer.zero_grad()
Device management — the #1 source of runtime errors
device = 'cuda' if torch.cuda.is_available() else 'cpu'. On Mac: 'mps'. Or force with an env var.
model.to(device). This moves EVERY parameter tensor to that device.
x, y = x.to(device), y.to(device). DataLoader gives you CPU tensors by default.
torch.zeros(10, device=device) — NOT torch.zeros(10).to(device) if you can help it (avoid the roundtrip).
You'll see ‘Expected all tensors to be on the same device’ — that error tells you exactly which line to fix.
nn.Module — the pattern for every model
nn.Module anatomy
"PyTorch tensors are basically NumPy arrays that run on the GPU. If I know NumPy, I know tensors — the only difference is .cuda()."
The defining difference is not the device, it is autograd. A tensor with requires_grad=True silently records every operation applied to it into a computation graph, holding references to intermediate results so gradients can be computed later. That means a tensor is not just data — it can be the root of a graph that keeps arbitrary amounts of memory alive. Treating it as a NumPy array is exactly how you leak GPU memory over a training loop.
The myth is sticky because the API was deliberately designed to feel like NumPy — same indexing, same broadcasting, same method names — so the analogy is confirmed constantly during ordinary use. Nothing in the syntax signals that a graph is being built. The failure only appears in a specific situation: accumulating a loss tensor across a loop for logging. total += loss looks identical to NumPy and quietly retains every graph from every iteration, so memory grows until the process dies with an OOM whose traceback points at an innocent line.
Watch the graph keep memory alive, and watch two ways of detaching it fix the problem:
total = 0
for batch in loader:
loss = model(batch).mean()
loss.backward(); opt.step(); opt.zero_grad()
total += loss # BUG: retains the graph every iteration
# correct -- take the number out of the graph:
total += loss.item() # python float, no graph
total += loss.detach() # tensor, graph reference dropped
print(torch.cuda.memory_allocated() / 1e6, 'MB')
# the buggy version climbs monotonically; the fixed one is flatWhy do PyTorch tensor views — .view(), .transpose(), slicing — cost nothing and allocate no memory, while .reshape() sometimes silently copies and .contiguous() exists at all?
- 1A tensor is not a nested array. It is a flat one-dimensional block of memory plus three pieces of metadata: shape, stride (how many elements to skip to advance one step along each dimension), and a storage offset.forced by · hardware memory is linear, so any multidimensional structure must be an interpretation imposed on a flat buffer
- 2Indexing is therefore pure arithmetic: element (i, j) lives at
offset + i·stride₀ + j·stride₁. No pointer chasing, no per-row allocation.forced by · a strided layout makes address computation a dot product, which is why tensor ops vectorise well - 3So many "shape changes" are just metadata edits. Transposing swaps two strides; slicing adjusts the offset and shape. The underlying buffer is untouched and shared — which is exactly why a view and its parent alias each other, and why mutating one changes the other.forced by · if you never move data, two tensors necessarily point at the same bytes
- 4But not every shape is expressible with strides over a given buffer. After a transpose the elements are no longer laid out in row-major order, so a subsequent
view()that requires contiguous ordering has no valid stride pattern and PyTorch raises an error rather than silently guessing.forced by · a strided view can only reinterpret an existing layout, never rearrange it - 5Therefore you need an escape hatch that physically copies elements into a fresh contiguous buffer. That is
.contiguous(), and.reshape()is the convenience wrapper that returns a view when one is possible and copies when it is not.forced by · an operation that must always succeed cannot always be free
Therefore views are free because tensors are metadata over shared storage, and copies happen exactly when the requested layout is not reachable by any stride pattern.
And note what this predicts: performance must depend on memory layout, not just on operation count. A contiguous tensor is read in cache-friendly sequential order; a transposed one strides across memory and is measurably slower for the identical arithmetic. It also predicts the classic aliasing bug — modify a slice in place and the parent changes too, because they were never separate data. Both follow from one design decision, and neither is surprising once you hold the strides picture.
Three layers stacked on each other. At the bottom is a flat block of memory on some device. In the middle is metadata — shape, strides, dtype, offset — which is how that flat block gets interpreted as an n-dimensional object; this layer is cheap to change and is what makes views free. On top, optionally, is the autograd tape: if requires_grad is set, every operation appends a node recording what happened and what it needs to differentiate itself.
Almost every PyTorch bug is a confusion about which layer you are touching. Device errors are the bottom layer. Shape and aliasing errors are the middle. Memory leaks, "element 0 does not require grad", and unexpectedly slow inference are the top.
- Anything you keep for logging must leave the graph:
.item()for a scalar,.detach()for a tensor. Otherwise you retain the entire graph that produced it. - Wrap evaluation in
torch.no_grad()(orinference_mode()). It skips graph construction entirely — less memory, faster, and it prevents accidental gradient flow. - The training loop is always:
zero_grad → forward → loss → backward → step. Gradients accumulate by default, so omittingzero_gradsums across batches silently. - Every tensor in an operation must share a device and a compatible dtype. Host-to-device transfers are expensive, so move data once and keep it there rather than crossing the boundary in the inner loop.
Fire this the moment you see: GPU memory climbing steadily across epochs · a loss accumulated without .item() · evaluation running without no_grad · a view() failing after a transpose · in-place ops breaking autograd · a .cpu() or .numpy() call inside a training loop · a model left in train mode during validation.
Where do you put data augmentation and preprocessing — in the Dataset (CPU workers), on the GPU as part of the forward pass, or precomputed offline?
num_workers the work happens in parallel processes overlapping GPU compute, so it is often effectively freeSplit by determinism: precompute everything deterministic and expensive, and keep random augmentation in the Dataset where it can differ every epoch. That combination usually removes the bottleneck without giving up the regularisation, and it is a one-time change rather than an ongoing tax.
Before optimising any of this, measure. Watch GPU utilisation during training: if it is pinned high, the input pipeline is not your problem and time spent there is wasted. If it oscillates between full and idle, you are data-starved and more workers, prefetching, or pinned memory will help immediately. The instinct to rewrite the pipeline before profiling is how a lot of engineering time gets spent on the wrong bottleneck.
(c) Hands-on · 25 min
Port the numpy MLP from S097 to PyTorch, train on MNIST, hit the same 96%+ accuracy — but now with GPU support, mixed precision, and 5× less code. Save as pytorch_lab.py, uv pip install torch torchvision, uv run pytorch_lab.py.
"""pytorch_lab.py — the same MLP as session 097, in PyTorch.
Runs on CPU by default. If CUDA is available it will use GPU automatically.
"""
from __future__ import annotations
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {DEVICE}")
# ---------------- model ----------------
class MLP(nn.Module):
"""784 → 128 → 64 → 10, ReLU hidden, softmax done by CrossEntropyLoss."""
def __init__(self, in_dim: int = 784, h1: int = 128, h2: int = 64,
out_dim: int = 10) -> None:
super().__init__()
self.fc1 = nn.Linear(in_dim, h1)
self.fc2 = nn.Linear(h1, h2)
self.fc3 = nn.Linear(h2, out_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = x.view(x.size(0), -1) # flatten (B, 1, 28, 28) → (B, 784)
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
return self.fc3(x) # logits — no softmax, CE will do it
# ---------------- data ----------------
def make_loaders(batch: int = 128):
tfm = transforms.Compose([
transforms.ToTensor(), # [0, 1] float
transforms.Normalize((0.1307,), (0.3081,)), # MNIST mean/std
])
root = "./data"
train_ds = datasets.MNIST(root, train=True, download=True, transform=tfm)
test_ds = datasets.MNIST(root, train=False, download=True, transform=tfm)
train_ld = DataLoader(train_ds, batch_size=batch, shuffle=True, num_workers=2)
test_ld = DataLoader(test_ds, batch_size=1024, shuffle=False, num_workers=2)
return train_ld, test_ld
# ---------------- train / eval ----------------
def train_one_epoch(model, loader, optimizer, criterion) -> float:
model.train() # dropout/BN in training mode
running_loss = 0.0
for x, y in loader:
x, y = x.to(DEVICE), y.to(DEVICE)
optimizer.zero_grad() # clear stale gradients
logits = model(x) # forward
loss = criterion(logits, y) # CE
loss.backward() # autograd — compute grads
optimizer.step() # apply the update
running_loss += loss.item() * x.size(0)
return running_loss / len(loader.dataset)
@torch.no_grad() # turn off autograd → faster, less memory
def evaluate(model, loader, criterion) -> tuple[float, float]:
model.eval() # dropout/BN in eval mode
total_loss, correct, n = 0.0, 0, 0
for x, y in loader:
x, y = x.to(DEVICE), y.to(DEVICE)
logits = model(x)
total_loss += criterion(logits, y).item() * x.size(0)
correct += (logits.argmax(1) == y).sum().item()
n += x.size(0)
return total_loss / n, correct / n
def count_params(model: nn.Module) -> int:
return sum(p.numel() for p in model.parameters() if p.requires_grad)
if __name__ == "__main__":
torch.manual_seed(0)
train_ld, test_ld = make_loaders(batch=128)
model = MLP().to(DEVICE)
print(f"Model: {model}\n parameters: {count_params(model):,}")
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss() # softmax + NLL fused, numerically stable
for epoch in range(1, 11):
tr_loss = train_one_epoch(model, train_ld, optimizer, criterion)
te_loss, te_acc = evaluate(model, test_ld, criterion)
print(f"epoch {epoch:>2}: train_loss={tr_loss:.4f} "
f"test_loss={te_loss:.4f} test_acc={te_acc:.4f}")
# Save the trained model — deployment artifact
torch.save(model.state_dict(), "mlp_mnist.pt")
print("Saved to mlp_mnist.pt")
# Load in a fresh session:
# model = MLP(); model.load_state_dict(torch.load("mlp_mnist.pt")); model.eval()Anatomy of the script
Anatomy of the script
Instantiate the PyTorch MLP with fixed weights (copy the numpy W1, W2, W3 directly), run a single forward-backward on the same batch, and compare model.fc1.weight.grad to the numpy dW1 from session 98's script. They should match to ~1e-6 for float32. If they differ by more than that, either the initialisation differs or your numpy derivation has a bug you never caught. This is the equivalent of a gradient check — PyTorch's autograd IS your ground truth now.
(d) Production reality · 15 min
New user writes a training loop, forgets optimizer.zero_grad(), and observes loss going to NaN by epoch 3. Or loss stays flat and never learns. Or worse — model appears to train but reaches half the expected accuracy.
Root cause: gradients accumulated across ALL previous batches. By batch 500, the effective learning rate is ~500× what you set.
optimizer.zero_grad() BEFORE the forward pass (or before .backward() — anywhere in the loop is fine as long as it's before the next .backward()). PyTorch Lightning and other high-level libs do this automatically; if you write your own loop, this is on you.model.eval() and torch.no_grad(), so every inference builds an autograd graph and runs dropout.Production inference always: (1) model.eval() after loading; (2) wrap forward in with torch.no_grad(): or use the @torch.inference_mode() decorator (slightly faster than no_grad); (3) move to production-optimised runtimes (TorchScript, ONNX, or torch.compile).
Reproducibility checklist: torch.manual_seed + np.random.seed + random.seed + torch.backends.cudnn.deterministic = True + torch.backends.cudnn.benchmark = False + DataLoader(..., worker_init_fn=...). Even then, bitwise reproducibility across GPU/CPU is not guaranteed.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three to a friend without notes, redo the session:
- Write the 5-line PyTorch training step and name each line.
- What is autograd and how does it differ from what you did in numpy?
- Name the three most common PyTorch bugs and their fixes.
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.