Search Tech Journey

Find topics, journeys and posts

6-month learning plan100 / 130
back to blog
mladvanced 55m read

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.

🤖Machine LearningM12 · Deep Learning· Session 100 of 130 90 min

🎯 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.

You will be able to
  • 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

From tools you built to tools you use
🌍 Real world

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.

💻 Code world

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

PyTorch in three concepts
  • 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

  1. 2002
    Torch (Lua) · Collobert
    First iteration of the framework, in Lua. Used by Facebook AI Research.
  2. 2015
    TensorFlow 1 · Google
    Static computation graph, sessions. Dominant for 3 years.
  3. 2017
    PyTorch 0.1 · Facebook
    Dynamic graph (‘define-by-run’). Wins researchers within 18 months for its debuggability.
  4. 2018
    PyTorch 1.0 · production ready
    TorchScript for deployment, ONNX export.
  5. 2019
    PyTorch overtakes TensorFlow in research
    By NeurIPS 2019, PyTorch is in the majority of accepted papers.
  6. 2023
    PyTorch 2.0 · torch.compile
    JIT 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

numpy

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]
PyTorch

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

11
Pick device

device = 'cuda' if torch.cuda.is_available() else 'cpu'. On Mac: 'mps'. Or force with an env var.

22
Move model

model.to(device). This moves EVERY parameter tensor to that device.

33
Move every data batch

x, y = x.to(device), y.to(device). DataLoader gives you CPU tensors by default.

44
Everything created after must be on device

torch.zeros(10, device=device) — NOT torch.zeros(10).to(device) if you can help it (avoid the roundtrip).

5check
Rule: mixed-device operations crash

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

class MyModel(nn.Module):
Every model, layer, or block inherits from nn.Module. Even losses (nn.CrossEntropyLoss) do.
core
def __init__(self):
Call super().__init__() first. Then declare every learnable component as self.something = nn.Linear(...) / nn.Conv2d(...) / etc.
declare
def forward(self, x):
Define the computation. Never call this directly — call model(x) which does forward + autograd tracking.
compute
model.parameters()
Returns every trainable tensor. Pass to optimizer: torch.optim.Adam(model.parameters(), lr=1e-3).
params
model.train() / model.eval()
Switches modes for dropout and batchnorm. Training mode is default; call eval() before validation and inference.
mode
with torch.no_grad():
Turns off autograd for inference. Makes the forward pass ~2× faster and saves memory. Combine with model.eval().
infer

Common misconception
✗ What most people think

"PyTorch tensors are basically NumPy arrays that run on the GPU. If I know NumPy, I know tensors — the only difference is .cuda()."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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 flat
From first principles
Start with the question

Why do PyTorch tensor views — .view(), .transpose(), slicing — cost nothing and allocate no memory, while .reshape() sometimes silently copies and .contiguous() exists at all?

  1. 1
    A 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
  2. 2
    Indexing 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
  3. 3
    So 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
  4. 4
    But 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
  5. 5
    Therefore 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

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.

Mental modelTensor = buffer + metadata + an optional tape

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() (or inference_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 omitting zero_grad sums 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.
🔔 Fires when you see

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.

The tradeoff

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?

In the Dataset, on CPU workers
+ you gain the standard pattern and the most flexible: arbitrary Python, any library, per-sample randomness, and with num_workers the work happens in parallel processes overlapping GPU compute, so it is often effectively free
− you pay CPU can become the bottleneck for heavy transforms or fast GPUs, leaving the accelerator idle waiting for batches; each worker is a separate process with its own memory copy; and worker startup plus inter-process transfer adds per-epoch overhead that hurts on small datasets
pick when transforms are moderate, you have CPU cores to spare, and you need the flexibility — the correct default
On GPU inside the forward pass
+ you gain augmentation runs on hardware that is already fast at elementwise and convolutional operations, and it happens per batch rather than per sample, so it can be dramatically faster; it also removes CPU from the critical path entirely, which matters when your GPU is fast enough to starve
− you pay consumes GPU memory and compute that would otherwise go to the model, so effective batch size shrinks; transforms must be expressible as tensor operations, ruling out much of the CPU imaging ecosystem; and it complicates the model code by mixing data logic into the forward pass
pick when the profiler shows GPU utilisation well below capacity while CPU workers are saturated, and the transforms are tensor-expressible
Precomputed offline
+ you gain zero cost at training time and perfectly reproducible, since every epoch sees identical inputs; ideal for expensive deterministic preprocessing — resizing, tokenisation, feature extraction — that would otherwise be repeated every epoch for no benefit
− you pay eliminates per-epoch randomness, which destroys most of augmentation's regularising value; multiplies storage; and freezes your preprocessing, so every change requires regenerating the whole dataset, which slows experimentation considerably
pick when deterministic transforms only (resize, normalise, tokenise) — never for random augmentation, where seeing a different variant each epoch is the entire mechanism
What a senior engineer actually does

Split 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

Line 11 · DEVICE = 'cuda' if available else 'cpu'
The single line that lets your code work on any machine. On Mac use 'mps'. Every real PyTorch codebase has this pattern.
device
Line 20 · super().__init__()
MUST call the parent Module's __init__. Without it, self.parameters() returns empty and the optimiser has nothing to update.
gotcha
Line 22 · nn.Linear(in_dim, h1)
PyTorch's equivalent of the (W, b) pair you built in numpy. He/Kaiming initialisation is applied automatically for ReLU-friendly weights.
layer
Line 30 · self.fc3(x) # logits — no softmax
PyTorch's nn.CrossEntropyLoss expects RAW LOGITS and applies log_softmax internally for numerical stability. Adding your own softmax is bug #1 for PyTorch beginners.
footgun
Line 51 · optimizer.zero_grad()
Clears .grad on every parameter. PyTorch accumulates gradients across .backward() calls; without zero_grad you're summing gradients across batches.
critical
Line 53 · loss.backward()
Autograd walks the computation graph backward and populates .grad on every leaf tensor with requires_grad=True. Exactly what our numpy backward() function did.
autograd
Line 54 · optimizer.step()
Reads .grad on every parameter and applies the Adam update. Same math as session 099.
optim
Line 60 · @torch.no_grad()
Decorator that disables gradient tracking for the whole function. Inference is faster and uses less memory.
infer
Line 63 · model.eval() / model.train()
Switches dropout and batchnorm to inference mode. Forgetting this in validation produces silently worse accuracy.
mode
Try itProve that PyTorch autograd matches your hand-rolled backprop

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.

💡 Hint · Same architecture, same init, same data → same gradients to floating-point precision.

(d) Production reality · 15 min

War story Every PyTorch user · common failure modeuniversal
🔥 What broke

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.

🧯 The fix
Every training iteration must call 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.
🎓 Lesson to steal
Autograd ADDS to .grad; it does not REPLACE. This design choice enables gradient accumulation (used to simulate large batches on small GPUs) but bites everyone the first time. If your model won't train, check zero_grad() before you check anything else.
Post-mortem
War story Meta / OpenAI / everyone shipping LLMsproduction LLM serving
🔥 What broke
A team wraps their model in a FastAPI endpoint. Latency is 10× what benchmarks showed. Investigation: they forgot model.eval() and torch.no_grad(), so every inference builds an autograd graph and runs dropout.
🧯 The fix

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).

🎓 Lesson to steal
The gap between ‘training loop code’ and ‘inference server code’ is roughly 3 lines but can be a 10× latency and memory difference. Every serving path must eval() + no_grad(). Full stop.
War story Kaggle competitors · reproducibility failuresthousands of frustrated users
🔥 What broke
Kaggle competitor gets a great score, publishes their notebook, next runner gets a completely different result on the same seed. Root cause: cudnn nondeterminism + threading in DataLoader + accumulated float32 noise across ops.
🧯 The fix

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.

🎓 Lesson to steal
Full reproducibility in PyTorch on GPU is hard. Aim for ‘statistically reproducible’ (same distribution across runs) rather than ‘bitwise reproducible’, and document your seed setup.
Post-mortem

Where this shows up in the rest of the plan

Every downstream DL session uses PyTorch
S101 · Regularisation
nn.Dropout, nn.BatchNorm1d, weight_decay in AdamW.
S102 · CNNs
nn.Conv2d + nn.MaxPool2d — same pattern as nn.Linear, different op.
S103 · RNN & LSTM
nn.LSTM, nn.GRU — one line replaces 50 lines of hand-rolled recurrent code.
S105 · Transformers
nn.MultiheadAttention or (better) build from scratch to understand — same nn.Module pattern.
S117 · Fine-tuning LLMs
Hugging Face transformers is a huge PyTorch wrapper. Same forward/loss/backward/step.
S128 · MLOps monitoring
TorchScript / ONNX export for production; PyTorch Lightning for training-loop hygiene.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three to a friend without notes, redo the session:

  1. Write the 5-line PyTorch training step and name each line.
  2. What is autograd and how does it differ from what you did in numpy?
  3. 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.