TAI BUI
Back to curriculum
BuildPython

Introduction to PyTorch

You built the engine from pistons and crankshafts. Now learn the one everyone actually drives.

TIME: ~75 minutesPREREQUISITES: Lesson 03.10 (Build Your Own Mini Framework)

Learning Objectives

  • Build and train neural networks using PyTorch's nn.Module, nn.Sequential, and autograd
  • Use PyTorch tensors, GPU acceleration, and the standard training loop (zero_grad, forward, loss, backward, step)
  • Convert your from-scratch mini framework components to their PyTorch equivalents
  • Profile and compare training speed between your pure-Python framework and PyTorch on the same task

The Problem

You've got a working mini framework: linear layers, ReLU, dropout, batch norm, Adam, a DataLoader, a training loop. It trains a 4-layer network on a circle classification problem in pure Python.

It's also 500x slower than PyTorch on the same problem.

Your mini framework processes one sample at a time with nested Python loops. PyTorch dispatches the same operations to optimized C++/CUDA kernels that run on GPU. On a single NVIDIA A100, PyTorch trains a ResNet-50 (25.6M parameters) on ImageNet (1.28M images) in about 6 hours. Your framework would take roughly 3,000 hours on the same task, assuming it didn't run out of memory first.

Speed isn't the only gap. Your framework has no GPU support. No automatic differentiation: you hand-wrote backward() for every module. No serialization. No distributed training. No mixed precision. No way to debug gradient flow without print statements.

PyTorch fills nearly every one of these gaps as a framework (distributed training is its own topic later in the curriculum), and it does so while keeping the exact same mental model you already built: Module, forward(), parameters(), backward(), optimizer.step(). The concepts transfer one-to-one. The syntax is nearly identical. What's different is that PyTorch wraps a decade of systems engineering behind the same interface you designed from scratch.

Warm-up0/3
1. During training, what does the gradient of the loss with respect to one weight tell you?Warm-up
A curve of loss on the vertical axis against the value of one weight on the horizontal axis, a smooth bowl shape, with a marked dot labelled current w on its left side.
Illustrative curve, not measured from a real network.
2. Why do you score a model on a held-out test set instead of only on the data it trained on?Warm-up
A row of ten labelled examples: the first eight are marked as the data the model is trained on, the last two are marked held out; a model box points to the first eight with a note that its weights are fitted to them.
Illustrative split of ten examples.
3. What is dropout used for when training a neural network?Warm-up
Eight circles, one hidden-layer activation each, with their values from torch.rand, feed into a box labelled nn.Dropout(0.2) in training mode, which feeds the next layer.
Values from torch.rand(8) * 0.85 + 0.1 with seed 3 (illustrative).
Check: The Problem0/3
1. Besides raw speed, which gaps in the hand-built mini framework does PyTorch fill?Explain
Two cards describing the Lesson 10 mini framework: its parts (linear layers, ReLU, dropout, batch norm, Adam, DataLoader, training loop, loss with backward) and its use (a 4-layer network on circle classification in pure Python, one sample at a time, about 300 s per epoch on an MNIST MLP).
Contents as described in the lesson text and its comparison table.
2. The mini framework and PyTorch share Module, forward(), parameters(), backward() and optimizer.step(). What does that mean for porting your mini framework code?Explain
Two cards side by side, Mini framework (Lesson 10) and PyTorch, each listing the same five names: Module, forward(), parameters(), backward() and optimizer.step().
The shared names are the ones the question lists.
3. Why does PyTorch train the same network so much faster than the pure-Python mini framework?Explain
Two horizontal bars on a log time axis from 0.1 to 1000 seconds: the mini framework at about 300 seconds per epoch and PyTorch at about 0.5 seconds per epoch on the same MNIST MLP.
Per-epoch times from the lesson's comparison table.

The Concept

Why PyTorch Won

In 2015, TensorFlow required you to define a static computation graph before running anything. You built the graph, compiled it, then fed data through it. Debugging meant staring at graph visualizations. Changing the architecture meant rebuilding the graph from scratch.

PyTorch launched in 2017 with a different philosophy: eager execution. You write Python. It runs immediately. y = model(x) actually computes y right now, not "add a node to a graph that will compute y later." This meant standard Python debugging tools worked. print() worked. pdb worked. if/else in your forward pass worked.

By 2020, the market had spoken. PyTorch's share in ML research papers went from 7% (2017) to over 75% (2022). Meta, Google DeepMind, OpenAI, Anthropic, and Hugging Face all use PyTorch as their primary framework. TensorFlow 2.x adopted eager execution in response: a tacit admission that PyTorch's design was correct.

The lesson: developer experience compounds. A framework that's 10% slower but 50% faster to debug wins every time.

Tensors

A tensor is a multi-dimensional array with three critical properties: shape, dtype, and device.

python
import torch
 
x = torch.zeros(3, 4)           # shape: (3, 4), dtype: float32, device: cpu
x = torch.randn(2, 3, 224, 224) # batch of 2 RGB images, 224x224
x = torch.tensor([1, 2, 3])     # from a Python list

Shape is the dimensionality. A scalar is shape (), a vector is (n,), a matrix is (m, n), a batch of images is (batch, channels, height, width).

Dtype controls precision and memory.

dtypeBitsRangeUse case
float3232~7 decimal digitsDefault training
float1616~3.3 decimal digitsMixed precision
bfloat1616Same range as float32, less precisionLLM training
int88-128 to 127Quantized inference

Device determines where computation happens: cpu runs on the processor, cuda targets an NVIDIA GPU (AMD's ROCm and Apple's MPS cover the same job on their own hardware).

python
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
x = torch.randn(3, 4, device=device)
x = x.to("cuda")
x = x.cpu()

Every operation requires all tensors on the same device. This is the #1 PyTorch error beginners hit: RuntimeError: Expected all tensors to be on the same device. Fix it by moving everything to the same device before computation.

Reshaping is constant-time: it changes the metadata, not the data.

python
x = torch.randn(2, 3, 4)
x.view(2, 12)      # reshape to (2, 12) -- must be contiguous
x.reshape(6, 4)    # reshape to (6, 4) -- works always
x.permute(2, 0, 1) # reorder dimensions
x.unsqueeze(0)     # add dimension: (1, 2, 3, 4)
x.squeeze()        # remove size-1 dimensions

Autograd

PyTorch's autograd records every operation on tensors into a directed acyclic graph (the computational graph), then traverses that graph in reverse to compute gradients automatically. Your mini framework never gave you this: you hand-wrote backward() for every module.

Mermaid diagram

Mechanically, this runs on tape-based autodiff: every operation appends to a "tape" during the forward pass, and calling .backward() replays the tape in reverse.

python
x = torch.randn(3, requires_grad=True)
y = x ** 2 + 3 * x
z = y.sum()
z.backward()
print(x.grad)  # dz/dx = 2x + 3

Three rules of autograd:

  1. Only leaf tensors with requires_grad=True accumulate gradients
  2. Gradients accumulate by default: call optimizer.zero_grad() before each backward pass
  3. torch.no_grad() disables gradient tracking (use during evaluation)

nn.Module

nn.Module is the base class for every neural network component in PyTorch. You already built this abstraction in Lesson 10. PyTorch's version adds automatic parameter registration, recursive module discovery, device management, and state dict serialization.

python
import torch.nn as nn
 
class MLP(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.layer1 = nn.Linear(input_dim, hidden_dim)
        self.relu = nn.ReLU()
        self.layer2 = nn.Linear(hidden_dim, output_dim)
 
    def forward(self, x):
        x = self.layer1(x)
        x = self.relu(x)
        x = self.layer2(x)
        return x

When you assign an nn.Module or nn.Parameter as an attribute in __init__, PyTorch automatically registers it. model.parameters() recursively collects every registered parameter. This is why you never have to manually gather weights like you did in the mini framework.

Key building blocks:

ModuleWhat it doesParameters
nn.Linear(in, out)Wx + bin*out + out
nn.Conv2d(in_ch, out_ch, k)2D convolutionin_chout_chk*k + out_ch
nn.BatchNorm1d(features)Normalize activations2 * features
nn.Dropout(p)Random zeroing0
nn.ReLU()max(0, x)0
nn.GELU()Gaussian error linear0
nn.Embedding(vocab, dim)Lookup tablevocab * dim
nn.LayerNorm(dim)Per-sample normalization2 * dim

Loss Functions and Optimizers

PyTorch ships production-ready versions of everything you built.

Loss functions (from torch.nn):

LossTaskInput
nn.MSELoss()RegressionAny shape
nn.CrossEntropyLoss()Multi-class classificationLogits (not softmax)
nn.BCEWithLogitsLoss()Binary classificationLogits (not sigmoid)
nn.L1Loss()Regression (robust)Any shape
nn.CTCLoss()Sequence alignmentLog probabilities

Note: CrossEntropyLoss combines LogSoftmax + NLLLoss internally. Pass raw logits, not softmax outputs. This is a common mistake that produces wrong gradients silently.

Optimizers (from torch.optim):

OptimizerWhen to useTypical LR
SGD(params, lr, momentum)CNNs, well-tuned pipelines0.01--0.1
Adam(params, lr)Default starting point1e-3
AdamW(params, lr, weight_decay)Transformers, fine-tuning1e-4--1e-3
LBFGS(params)Small-scale, second-order1.0

The Training Loop

Every PyTorch training loop follows the same 5-step pattern. You already know this from Lesson 10.

Mermaid diagram

The canonical pattern:

python
for epoch in range(num_epochs):
    model.train()
    for inputs, targets in train_loader:
        inputs, targets = inputs.to(device), targets.to(device)
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, targets)
        loss.backward()
        optimizer.step()

Five lines inside the batch loop. Five lines that trained GPT-4, Stable Diffusion, and LLaMA. The architecture changes. The data changes. These five lines do not.

Dataset and DataLoader

PyTorch's Dataset is an abstract class with two methods: __len__ and __getitem__. DataLoader wraps it with batching, shuffling, and multi-process data loading.

python
from torch.utils.data import Dataset, DataLoader
 
class MNISTDataset(Dataset):
    def __init__(self, images, labels):
        self.images = images
        self.labels = labels
 
    def __len__(self):
        return len(self.labels)
 
    def __getitem__(self, idx):
        return self.images[idx], self.labels[idx]
 
loader = DataLoader(dataset, batch_size=64, shuffle=True, num_workers=4)

num_workers=4 spawns 4 processes to load data in parallel while the GPU trains on the current batch. On disk-bound workloads (large images, audio), this alone can double training speed.

GPU Training

Moving a model to GPU:

python
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)

This recursively moves every parameter and buffer to the GPU. Then move each batch during training:

python
inputs, targets = inputs.to(device), targets.to(device)

Mixed precision halves memory usage and doubles throughput on modern GPUs (A100, H100, RTX 4090) by running forward/backward in float16 while keeping the master weights in float32:

python
from torch.amp import autocast, GradScaler
 
scaler = GradScaler()
for inputs, targets in loader:
    with autocast(device_type="cuda"):
        outputs = model(inputs)
        loss = criterion(outputs, targets)
    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()
    optimizer.zero_grad()

Comparison: Mini Framework vs PyTorch vs JAX

JAX is Google's array library: the same NumPy-style operations as PyTorch, but built around composable function transforms (grad, jit, vmap) instead of an object-oriented Module system. You'll build with it directly in the next lesson; for now, here's how the three stack up.

FeatureMini Framework (L10)PyTorchJAX
AutodiffManual backward()Tape-based autogradFunctional transforms
ExecutionEager (Python loops)Eager (C++ kernels)Traced + JIT compiled
GPU supportNoYes (CUDA, ROCm, MPS)Yes (CUDA, TPU)
Speed (MNIST MLP)~300s/epoch~0.5s/epoch~0.3s/epoch
Module systemCustom Module classnn.ModuleStateless functions (Flax/Equinox)
Debuggingprint()print(), pdb, breakpoint()Harder (JIT tracing breaks print)
EcosystemNoneHugging Face, Lightning, timmFlax, Optax, Orbax
Learning curveYou built itModerateSteep (functional paradigm)
Production useToy problemsMeta, OpenAI, Anthropic, HFGoogle DeepMind, Midjourney
Check: The Concept0/27
1. What is eager execution?Define
A Python session: import torch, x = torch.tensor([1.0, 2.0, 3.0]), y = x * 2, print(y), and its printed output tensor([2., 4., 6.]).
Real session, torch 2.14.1 on CPU.
2. What is a tensor in PyTorch?Define
A Python session creating x = torch.randn(2, 3, requires_grad=True), printing its values with requires_grad=True, and printing its shape (2, 3), dtype float32 and device cpu.
Real session, torch 2.14.1 on CPU, seed 0.
3. What is autograd in PyTorch?Define
Three lines of code define x with requires_grad, y = x ** 2 + 3 * x and z = y.sum(); below, a graph of boxes x, x ** 2 and 3 * x, y, z, each labelled with its real grad_fn name (Pow, Mul, Add, Sum).
grad_fn names read from a real run, torch 2.14.1.
4. What is nn.Module?Define
The lesson's MLP class with layer1 = nn.Linear, relu = nn.ReLU and layer2 = nn.Linear, followed by the real printed repr of MLP(4, 8, 2) listing its three child modules.
Real output, torch 2.14.1.
5. What does optimizer.zero_grad() do?Define
A tiny SGD example: w = [1.0, 2.0] with requires_grad, loss = (w ** 2).sum(), loss.backward(), then w.grad printed as tensor([2., 4.]); the next line, optimizer.zero_grad(), is highlighted as about to run.
Real run, torch 2.14.1.
6. What does calling loss.backward() do?Define
The lesson's example with x = [1.0, 2.0, 3.0] requiring grad and z = (x ** 2 + 3 * x).sum() printing tensor(32., grad_fn=SumBackward0); x.grad is None, and z.backward() is highlighted as the next call.
Real run, torch 2.14.1. The lesson's code names the loss z; the question calls it loss.
7. What is a DataLoader?Define
A TensorDataset of ten samples with len 10 and ds[0] printed, then a DataLoader with batch_size=4 and shuffle=True; looping over it prints the label lists of three batches of sizes 4, 4 and 2.
Real run, torch 2.14.1, seed 0.
8. What does model.to(device) do?Define
A table of the six parameters of MNISTModel (net.0 to net.6 weights and biases) with their shapes and device cpu, above the next line of code, model = model.to(device).
Real parameter listing, torch 2.14.1.
9. What is mixed precision training?Define
Two stacked bar diagrams of number formats drawn to scale: float32 as 1 sign bit, 8 exponent bits and 23 mantissa bits, and float16 as 1, 5 and 10 bits, with each format's largest value and machine epsilon.
Bit split derived from torch.finfo (torch 2.14.1).
10. Why did PyTorch overtake TensorFlow 1.x in research use?Explain
A timeline with TensorFlow 1.x in 2015, PyTorch launching in 2017 and PyTorch in over 75 percent of research papers in 2022, plus a two-bar chart of PyTorch's research-paper share: 7 percent in 2017 and 75 percent in 2022.
All figures are the ones stated in the lesson text.
11. Why is reshaping a tensor with view() or reshape() cheap?Explain
A 2 by 3 by 4 tensor made with torch.arange(24).reshape(2, 3, 4) drawn as two 3 by 4 grids holding 0 to 23, beside a REPL showing numel 24, view(2, 12) giving shape (2, 12) and reshape(6, 4) giving shape (6, 4).
Shapes from a real run, torch 2.14.1.
12. What happens automatically when you assign an nn.Module or nn.Parameter as an attribute in __init__?Explain
The lesson's MLP class with the two attribute assignments self.layer1 = nn.Linear(...) and self.layer2 = nn.Linear(...) highlighted, followed by model = MLP(4, 8, 2).
13. What causes `RuntimeError: Expected all tensors to be on the same device`?Explain
A trimmed terminal traceback ending in RuntimeError: Expected all tensors to be on the same device.
Typed, not captured: this machine has no GPU, so only the message quoted in the question is shown.
14. What does mixed precision buy you on a modern GPU such as an A100?Explain
The lesson's mixed precision training loop with GradScaler and a with autocast(device_type="cuda") block around the forward pass and loss; the autocast block is highlighted.
Code copied from the lesson text.
15. What are the dtype and device of x = torch.zeros(3, 4)?Apply
The tensor from torch.zeros(3, 4) drawn as a grid of 3 rows and 4 columns of zeros.
No dtype or device is shown here.
16. For x = torch.randn(2, 3, 4), what is the shape of x.permute(2, 0, 1)?Apply
Three boxes for the three dimensions of x = torch.randn(2, 3, 4): dimension 0 with size 2, dimension 1 with size 3 and dimension 2 with size 4.
17. With x = torch.tensor([1.0, 2.0, -1.0], requires_grad=True), y = x ** 2 + 3 * x and z = y.sum(), what does x.grad hold after z.backward()?Apply
Code: x = [1.0, 2.0, -1.0] with requires_grad, y = x ** 2 + 3 * x, z = y.sum(), z.backward(), then print(x.grad) is highlighted with a question mark.
18. How many learnable parameters does nn.Linear(784, 256) have?Apply
A large rectangle for nn.Linear(784, 256)'s weight tensor of shape (256, 784), labelled with 784 columns, one per input feature, and 256 rows, one per output, above a thin bar labelled bias with shape (256,).
19. A binary classifier outputs one raw logit per example. Which loss fits it?Apply
A flow: input goes to a network, which outputs a single raw logit, for example 0.8, for an example whose label is 1 (the positive class).
The logit value 0.8 is illustrative.
20. A classifier ends with nn.Softmax and trains with nn.CrossEntropyLoss. It runs without errors but learns poorly. Which diagnosis fits?Analyze
A chain Linear, ReLU, Linear, Softmax, CrossEntropy (the last two highlighted) above a curve of training loss over 300 steps falling slowly from about 2.3 toward 1.7.
Real run on synthetic 10-class data (20 features), one hidden layer of 64.
21. A loop calls loss.backward() and optimizer.step() but never zero_grad(). Training behaves erratically from the second batch on. What is happening?Analyze
A training loop body with forward, loss, loss.backward() and optimizer.step() but no zero_grad, above a plot of its loss per batch over 40 batches, which falls and then turns erratic and climbs.
Real run on synthetic 10-class data, torch 2.14.1. In this run the loss falls for roughly the first 20 batches, then turns erratic and climbs.
22. Training on large, disk-bound image files shows low GPU utilization and slow epochs. Which change is most likely to help?Analyze
A pipeline of three boxes, disk with large image files, read and decode a batch, and GPU train step, above a utilization bar for the GPU that is mostly empty and labelled low, with a note that epochs are slow.
Schematic; no measured values.
23. Training crashes with 'Expected all tensors to be on the same device' right after model = model.to(device). Which line is most likely missing from the loop?Analyze
A training loop where the device is chosen and model = model.to(device) is called, followed by the epoch loop with model.train(), zero_grad, forward, loss, backward and step lines.
Code only: the error needs a GPU, which this machine lacks, so no traceback is shown.
24. A print() inside a JAX jit-compiled function behaves oddly, while the same print works in PyTorch. Which difference explains it?Analyze
Two panels run the same function (y = x * 2, print y, return y.sum()): in PyTorch it prints y = tensor([2., 4., 6.]) and returns 12.; in JAX under @jax.jit it prints y = JitTracer(float32[3]) and then 12.0.
Real runs, torch 2.14.1 and jax 0.11.2.
25. Your research model needs an if/else on tensor values inside forward(), and you want to step through it with pdb. Which design fits best?Design
A forward() method with an if h.mean() > 0 branch on a tensor value choosing head_a or head_b, and a breakpoint() call before returning.
Illustrative code written for this question.
26. You train a large language model and want 16-bit speed without float16's narrow numeric range. Which dtype fits?Design
A log-scale chart of the magnitude range of float32 (about 1.2e-38 to 3.4e38) and float16 (about 6.1e-5 to 65504), the float16 bar being much shorter.
Values from torch.finfo, torch 2.14.1.
27. You need faster steps and lower memory on an A100 without changing the network. Which change fits?Design
The plain training loop (zero_grad, forward, loss, backward, step) on an A100, with the goal of faster steps and lower memory and the constraint that the network stays the same.
Loop copied from the lesson.

Build It

A 3-layer MLP trained on MNIST using only PyTorch primitives. No high-level wrappers. No torchvision.datasets. We download and parse the raw data ourselves.

Step 1: Load MNIST From Raw Files

MNIST ships as 4 gzipped files: training images (60,000 x 28 x 28), training labels, test images (10,000 x 28 x 28), test labels. We download them and parse the binary format.

python
import torch
import torch.nn as nn
import struct
import gzip
import urllib.request
import os
 
def download_mnist(path="./mnist_data"):
    base_url = "https://storage.googleapis.com/cvdf-datasets/mnist/"
    files = [
        "train-images-idx3-ubyte.gz",
        "train-labels-idx1-ubyte.gz",
        "t10k-images-idx3-ubyte.gz",
        "t10k-labels-idx1-ubyte.gz",
    ]
    os.makedirs(path, exist_ok=True)
    for f in files:
        filepath = os.path.join(path, f)
        if not os.path.exists(filepath):
            urllib.request.urlretrieve(base_url + f, filepath)
 
def load_images(filepath):
    with gzip.open(filepath, "rb") as f:
        magic, num, rows, cols = struct.unpack(">IIII", f.read(16))
        data = f.read()
        images = torch.frombuffer(bytearray(data), dtype=torch.uint8)
        images = images.reshape(num, rows * cols).float() / 255.0
    return images
 
def load_labels(filepath):
    with gzip.open(filepath, "rb") as f:
        magic, num = struct.unpack(">II", f.read(8))
        data = f.read()
        labels = torch.frombuffer(bytearray(data), dtype=torch.uint8).long()
    return labels

Step 2: Define the Model

A 3-layer MLP: 784 -> 256 -> 128 -> 10. ReLU activations. Dropout for regularization. No batch norm to keep it simple.

python
class MNISTModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(784, 256),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(128, 10),
        )
 
    def forward(self, x):
        return self.net(x)

The output layer produces 10 raw logits (one per digit). No softmax: CrossEntropyLoss handles that internally.

Parameter count: 784256 + 256 + 256128 + 128 + 128*10 + 10 = 235,146. Tiny by modern standards. GPT-2 small has 124M. This trains in seconds.

Step 3: Training Loop

The canonical forward-loss-backward-step pattern.

python
def train_one_epoch(model, loader, criterion, optimizer, device):
    model.train()
    total_loss = 0
    correct = 0
    total = 0
    for images, labels in loader:
        images, labels = images.to(device), labels.to(device)
        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        total_loss += loss.item() * images.size(0)
        _, predicted = outputs.max(1)
        correct += predicted.eq(labels).sum().item()
        total += labels.size(0)
    return total_loss / total, correct / total
 
 
def evaluate(model, loader, criterion, device):
    model.eval()
    total_loss = 0
    correct = 0
    total = 0
    with torch.no_grad():
        for images, labels in loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            loss = criterion(outputs, labels)
            total_loss += loss.item() * images.size(0)
            _, predicted = outputs.max(1)
            correct += predicted.eq(labels).sum().item()
            total += labels.size(0)
    return total_loss / total, correct / total

Note torch.no_grad() during evaluation. This disables autograd, reducing memory usage and speeding up inference. Without it, PyTorch builds a computational graph you never use.

Step 4: Wire Everything Together

python
def main():
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
 
    download_mnist()
    train_images = load_images("./mnist_data/train-images-idx3-ubyte.gz")
    train_labels = load_labels("./mnist_data/train-labels-idx1-ubyte.gz")
    test_images = load_images("./mnist_data/t10k-images-idx3-ubyte.gz")
    test_labels = load_labels("./mnist_data/t10k-labels-idx1-ubyte.gz")
 
    train_dataset = torch.utils.data.TensorDataset(train_images, train_labels)
    test_dataset = torch.utils.data.TensorDataset(test_images, test_labels)
    train_loader = torch.utils.data.DataLoader(
        train_dataset, batch_size=64, shuffle=True
    )
    test_loader = torch.utils.data.DataLoader(
        test_dataset, batch_size=256, shuffle=False
    )
 
    model = MNISTModel().to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
 
    num_params = sum(p.numel() for p in model.parameters())
    print(f"Device: {device}")
    print(f"Parameters: {num_params:,}")
    print(f"Train samples: {len(train_dataset):,}")
    print(f"Test samples: {len(test_dataset):,}")
    print()
 
    for epoch in range(10):
        train_loss, train_acc = train_one_epoch(
            model, train_loader, criterion, optimizer, device
        )
        test_loss, test_acc = evaluate(
            model, test_loader, criterion, device
        )
        print(
            f"Epoch {epoch+1:2d} | "
            f"Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | "
            f"Test Loss: {test_loss:.4f} | Test Acc: {test_acc:.4f}"
        )
 
    torch.save(model.state_dict(), "mnist_mlp.pt")
    print(f"\nModel saved to mnist_mlp.pt")
    print(f"Final test accuracy: {test_acc:.4f}")

Expected output after 10 epochs: ~97.8% test accuracy. Training time on CPU: ~30 seconds. On GPU: ~5 seconds. On your mini framework with the same architecture: ~45 minutes.

Check: Build It0/5
1. Why does evaluate() wrap its loop in torch.no_grad()?Explain
The lesson's evaluate() function with the line with torch.no_grad(): highlighted above the loop over the loader.
Code copied from the lesson.
2. In MNISTModel, how many parameters does the layer nn.Linear(256, 128) have?Apply
The seven layers of MNISTModel in order across two rows: Linear(784, 256), ReLU, Dropout(0.2), Linear(256, 128) highlighted, then ReLU, Dropout(0.2) and Linear(128, 10).
Layer list from the lesson's model definition.
3. In evaluate(), outputs has shape (256, 10). What does `_, predicted = outputs.max(1)` put in predicted?Apply
A table of the first three of the 256 rows of the outputs tensor with shape (256, 10), columns c0 to c9 holding that sample's ten class scores, and below it the line _, predicted = outputs.max(1).
Values from torch.randn(256, 10) with seed 7 (illustrative), torch 2.14.1.
4. Validation accuracy swings between runs on the same checkpoint and the same validation set. What is the most likely cause?Analyze
A table of five evaluation runs of the same checkpoint on the same 500-sample validation set, with validation accuracies that differ slightly from run to run.
Real run: MNISTModel on synthetic 784-feature data (not MNIST), torch 2.14.1.
5. Evaluation uses far more GPU memory than expected and runs slowly. The loop calls model.eval() but not torch.no_grad(). Why?Analyze
An evaluate() function with model.eval() on the second line and a plain loop over the loader, with no torch.no_grad() block, producing outputs of shape (256, 10).
Lesson's evaluate() with the no_grad line removed, as the question describes.

Use It

Quick Comparison: Mini Framework vs PyTorch

Mini Framework (Lesson 10)PyTorch
model = Sequential(Linear(784, 256), ReLU(), ...)model = nn.Sequential(nn.Linear(784, 256), nn.ReLU(), ...)
pred = model.forward(x)pred = model(x)
optimizer.zero_grad()optimizer.zero_grad()
grad = criterion.backward() then model.backward(grad)loss.backward()
optimizer.step()optimizer.step()
No GPUmodel.to("cuda")
Manual backward for every moduleAutograd handles everything

The interface is nearly identical. The difference is everything under the hood.

Saving and Loading Models

Always save state_dict() (the parameter dictionary), not the model object: saving the object itself relies on pickle, which breaks the moment you refactor your code, while a state dict stays portable.

python
torch.save(model.state_dict(), "model.pt")
 
model = MNISTModel()
model.load_state_dict(torch.load("model.pt", weights_only=True))
model.eval()

Learning Rate Scheduling

A constant learning rate rarely stays optimal for an entire run, which is why Lesson 09 built one from scratch. PyTorch wires the same idea into the optimizer interface through a scheduler.

python
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer, T_max=10
)
for epoch in range(10):
    train_one_epoch(model, train_loader, criterion, optimizer, device)
    scheduler.step()

PyTorch ships 15+ schedulers: StepLR, ExponentialLR, CosineAnnealingLR, OneCycleLR, ReduceLROnPlateau. All plug into the same optimizer interface.

Check: Use It0/5
1. What is a state_dict?Define
A Python session building MLP(4, 8, 2), calling state_dict(), printing its type OrderedDict and each key with its tensor shape: layer1.weight (8, 4), layer1.bias (8,), layer2.weight (2, 8), layer2.bias (2,).
Real output, torch 2.14.1.
2. Why should you save a model's state_dict() rather than the model object itself?Explain
A REPL saving MNISTModel twice, as the whole object and as its state_dict, with the byte size of each file and the type each torch.load returns: MNISTModel for the object and OrderedDict for the state dict.
Real run, torch 2.14.1.
3. In the Use It comparison, which single PyTorch call replaces the mini framework's `grad = criterion.backward()` followed by `model.backward(grad)`?Apply
Two code panels of one training step: the mini framework's with grad = criterion.backward() and model.backward(grad) highlighted, and PyTorch's with a highlighted placeholder where the backward call goes.
Mini framework lines are from the Lesson 10 code (main.py). The PyTorch panel is typed, not captured, with the backward call hidden.
4. A loop runs 10 epochs with CosineAnnealingLR(optimizer, T_max=10). Where does it call scheduler.step()?Apply
A nested loop diagram: an outer for epoch in range(10) loop wrapping train_one_epoch, which loops over 938 batches (60,000 samples at batch size 64) each calling optimizer.step(); the scheduler is CosineAnnealingLR with T_max=10.
938 = ceil(60,000 / 64), the lesson's MNIST training set and batch size.
5. A teammate will load your trained model into a codebase you expect to refactor soon. What should you hand over?Design
Two boxes, your repo holding a trained MNISTModel of 235,146 parameters and a teammate's codebase that will be refactored soon, joined by an arrow labelled hand over.
Parameter count from the lesson's model definition.

Ship It

This lesson produces two artifacts:

  • outputs/prompt-pytorch-debugger.md, a prompt for diagnosing common PyTorch training failures
  • outputs/skill-pytorch-patterns.md, a skill reference for PyTorch training patterns
Author's Note

The first time you swap your hand-written backward() for a single loss.backward() call, it feels like cheating. You spent ten lessons earning that mental model, and PyTorch collapses it into one line. Good. The model still transfers completely: forward, loss, backward, step. What the hand-rolled version bought you wasn't speed, it was the ability to read a bad gradient's stack trace and know exactly which layer produced it.

Two bugs will eat your first week with PyTorch, and neither one is really a bug. The device-mismatch RuntimeError, because one tensor slipped past a .to(device) call. And silently wrong validation accuracy from forgetting model.eval(), so dropout keeps randomly zeroing activations at test time. Both are one-line fixes once you know to look for them. Now you do.

Exercises

  1. Add batch normalization. Insert nn.BatchNorm1d after each linear layer (before the activation). Compare test accuracy and training speed vs the dropout-only version. Batch norm should reach 98%+ in fewer epochs.

  2. Implement a learning rate finder. Train for one epoch with exponentially increasing learning rate (from 1e-7 to 1.0). Plot loss vs LR. The optimal LR is just before the loss starts climbing. Use this to pick a better LR for the MNIST model.

  3. Port to GPU with mixed precision. Add torch.amp.autocast and GradScaler to the training loop. Measure throughput (samples/second) with and without mixed precision on GPU. On an A100, expect ~2x speedup.

  4. Build a custom Dataset. Download Fashion-MNIST (same format as MNIST but with clothing items). Implement a FashionMNISTDataset(Dataset) class with __getitem__ and __len__. Train the same MLP and compare accuracy. Fashion-MNIST is harder: expect ~88% vs ~98%.

  5. Replace Adam with SGD + momentum. Train with SGD(params, lr=0.01, momentum=0.9). Compare convergence curves. Then add a CosineAnnealingLR scheduler and see if SGD catches up to Adam by epoch 10.

Key Terms

TermWhat people sayWhat it actually means
Tensor"A multi-dimensional array"A typed, device-aware array with automatic differentiation support baked into every operation
Autograd"Automatic backprop"A tape-based system that records operations during forward pass, then replays them in reverse to compute exact gradients
nn.Module"A layer"The base class for any differentiable computation block: registers parameters, supports nesting, handles train/eval modes
state_dict"The model weights"An OrderedDict mapping parameter names to tensors, the portable, serializable representation of a trained model
.backward()"Compute gradients"Traverse the computational graph in reverse, computing and accumulating gradients for every leaf tensor with requires_grad=True
.to(device)"Move to GPU"Recursively transfer all parameters and buffers to the specified device (CPU, CUDA, MPS)
DataLoader"The data pipeline"An iterator that batches, shuffles, and optionally parallelizes data loading from a Dataset
Mixed precision"Use float16"Train with float16 forward/backward for speed while keeping float32 master weights for numerical stability
Eager execution"Run it now"Operations execute immediately when called, not deferred to a later compilation step, the core design choice that differentiates PyTorch from TF 1.x
zero_grad"Reset gradients"Set all parameter gradients to zero before the next backward pass, since PyTorch accumulates gradients by default

Further Reading