
Book 11 of 50 · Free
Deep Learning with PyTorch: First Steps
28,899 words · 23 chapters · illustrated

Book 11 of 50 · Free
28,899 words · 23 chapters · illustrated
Book 11 of 50 — AstolixGen Learning Series

If you are an MS or PhD student, or an early AI researcher, there is one skill that will quietly decide how fast you can turn an idea into a published result: the ability to build, train, and debug a neural network yourself, in code. Papers do not come from reading about backpropagation. They come from running experiments — dozens of them — and knowing exactly what your code is doing at every step.
This book teaches you PyTorch, the deep learning framework that has become the working language of AI research. Almost every major result you read about in the last decade — from transformers to diffusion models — was prototyped in PyTorch. Conferences like NeurIPS, ICML, and ICLR are full of PyTorch code, and when a paper releases its code, it is most often a PyTorch repository. Learning PyTorch is not about learning one tool among many; it is about joining the conversation that research actually happens in.
You do not need any prior deep learning framework experience. This book assumes you know Python and basic machine learning concepts (what a model is, what training and test data are, what a loss function means at a high level — covered in Books 2 and 7 of this series). Everything else is built from scratch, one small working program at a time.
Every chapter in this book follows the same honest structure: a clear explanation in plain language, working code you can run immediately (mostly on a free Google Colab GPU), a "For Your Research" box that connects the chapter's ideas to real publication practice, and key takeaways. By the end, you will not just "know PyTorch" — you will have the habits of a researcher who runs clean, reproducible experiments: setting seeds, logging results, saving checkpoints, and organizing code so that six months later you (or a reviewer) can reproduce everything.
A note on honesty, because research demands it: this book contains no invented facts. Code examples are real, minimal, and tested against PyTorch's documented behavior. References are real papers you can look up. When something is a simplification, the book says so.
Prerequisites: Python (Book 3 of this series), basic machine learning concepts (Book 2), neural network basics (Book 7), and gradient descent intuition (Book 8). A Google account (for free Colab GPUs) is helpful but optional.
By the end of this book, you will be able to:
nn.Module, understanding parameters, layers, activation functions, and the forward pass.When a physicist starts a new project, one of the first questions is which lab instruments to use. When an AI researcher starts a new project, one of the first questions is which deep learning framework to build it in. The framework shapes everything: how fast you can test an idea, how easily others can read and reproduce your code, and whether the community's tools — pre-trained models, datasets, training utilities — plug straight into your work or fight you at every step.
There are three serious contenders today: PyTorch, TensorFlow, and JAX. All three can train neural networks. All three are used in industry. But for research — for the daily work of inventing new models, running ablations, and publishing papers — PyTorch has won, and it is worth understanding exactly why, so you can make the choice deliberately rather than by fashion.
Every deep learning framework must solve the same problem: a neural network is a long chain of math operations, and to train it you must compute gradients through that chain (backpropagation). The frameworks differ in when they build that chain.
TensorFlow (classic style) historically used a static graph: you first describe the entire computation — "take input x, multiply by matrix W, add bias b, apply ReLU" — as a graph structure, and only then do you run it with actual data. This is like writing a complete recipe before you are allowed to taste anything. It is efficient for production (the graph can be optimized, shipped to phones, served at scale), but it is awkward for research, because research is full of ideas like "what if the network's structure changes depending on the input?" or "let me print the value right here to see what's happening." In a static graph, you cannot easily print a value mid-computation or change the structure on the fly — the graph is already fixed.
PyTorch uses a dynamic graph (sometimes called "define by run"): the computation graph is built fresh every time your code runs, as a natural side effect of ordinary Python code executing. A loop in your code becomes a loop in the graph. An if statement becomes a branch in the graph. You can print any value, use a debugger, and change the architecture based on the data — because the network is just Python code running. This is the single most important reason researchers prefer PyTorch: debugging a PyTorch model feels like debugging any Python program, and experimenting feels like writing Python, not like configuring a separate graph language.
TensorFlow 2 added "eager execution" to close this gap, and JAX takes yet another approach (pure functions plus transformations like jit and grad, beloved for its mathematical elegance). But by the time the others caught up on usability, the research community had already standardized on PyTorch — and standardization itself is a powerful reason to join.
The honest reason most researchers use PyTorch today is not only technical. It is that everyone else does. Consider what that means in practice:
torchvision, torchaudio, and countless domain libraries distribute pre-trained models as PyTorch modules. Loading a pre-trained ResNet or BERT into your experiment is three lines of code.This is not to say TensorFlow is "bad." TensorFlow (with Keras) remains strong in production deployment, mobile, and some industry pipelines, and its SavedModel/TFLite ecosystem for shipping models is genuinely excellent. JAX is the rising star for researchers who want maximum mathematical control and speed, especially in areas like reinforcement learning and scientific machine learning — and it is worth learning later. But for your first framework, and for the fastest path into publishable research, PyTorch is the right choice. This book will note, at the end, exactly when to look at the others.
Beginners often think PyTorch is one library. In practice, "PyTorch" is a center of gravity with a large ecosystem around it:
torch — the core: tensors, autograd, neural network modules, optimizers.torchvision, torchaudio, torchtext — the domain libraries: standard datasets (MNIST, CIFAR, ImageNet), image transforms, and pre-trained models. Most computer vision papers start here.torch.compile for speed, and distributed training support for multi-GPU work.Learning core PyTorch first is the right order: every one of these tools assumes you understand tensors, autograd, modules, and the training loop. They are conveniences on top of foundations — and foundations are what this book builds.
PyTorch's philosophy is often summarized as staying close to Python and NumPy. If you know NumPy, tensors will feel familiar. If you know Python classes, nn.Module will feel familiar. The framework deliberately avoids magic: there is no hidden machinery you must appease, and almost everything you need to understand is visible in your own code. That is why this book teaches PyTorch "from the inside out" — tensors first, then gradients, then modules, then the loop — instead of handing you a high-level API that hides what is happening.
One more honest note: PyTorch will let you shoot yourself in the foot. It will happily let you forget to zero your gradients, train on the wrong device, or leak memory — silently. Chapter 10 exists because every researcher, including the authors of the papers you admire, has made every one of those mistakes. Knowing the failure modes is part of knowing the framework.
For Your Research
Reading paper code is a research skill. Pick one recent paper in your area that released code (check the paper's GitHub link or Papers with Code). Before you run anything, read its training script and identify: the model definition (
nn.Module), the loss function, the optimizer, and the training loop. You will find the exact four components this book teaches, in the same arrangement, in nearly every repository. Being able to map a paper's method section to these four code components is what turns "I read the paper" into "I can reproduce and extend the paper" — and that mapping is the hidden curriculum of this entire book. Start practicing it now, with Chapter 1's vocabulary: tensors, autograd, modules, losses, optimizers.
The dynamic-graph advantage is easiest to feel in code. Consider processing sentences of different lengths — a completely ordinary research situation. In PyTorch, you write the obvious Python:
import torch
import torch.nn as nn
rnn = nn.RNN(input_size=8, hidden_size=16, batch_first=True)
sentences = [torch.randn(1, 5, 8), torch.randn(1, 9, 8), torch.randn(1, 3, 8)]
for s in sentences:
out, h = rnn(s) # each forward pass builds a graph sized for THIS input
loss = out.sum()
loss.backward() # gradients flow through this pass's graph
print(s.shape[1], "->", h.shape)
No padding to a fixed length required by the framework, no graph-rebuilding ceremony, no separate "dynamic" API — the graph simply follows your code. You can put print() statements inside forward, set breakpoints, and wrap any of it in if statements driven by the data. When researchers say PyTorch is "Pythonic," this is what they mean: the distance between the idea in your head and the running code is minimal, and the distance between the running code and the debugger is zero.
A good researcher picks tools with eyes open. There are situations where the community's default shifts:
jit, grad, vmap) compose in ways PyTorch is still catching up to. If your research is about differentiation itself, learn JAX as your second framework (Chapter 12).None of these change the recommendation: learn PyTorch first, because it's where the papers, the code, and the collaborators are.
To feel the network effect concretely, this is how close "the entire field's pre-trained models" are:
from torchvision.models import resnet18, ResNet18_Weights
model = resnet18(weights=ResNet18_Weights.DEFAULT) # pre-trained on ImageNet, ready to fine-tune
One import, one line, and you hold a model that took weeks of GPU time to train — free, documented, and in the exact nn.Module format this book teaches. That is the practical meaning of "ecosystem": other people's compute, available to your experiments in seconds.
To make the "why" concrete, here's what a typical research day looks like once you're fluent. Morning: you read a paper proposing a new attention variant. By lunch, you've cloned the repo, found its model.py, and located the attention layer — an nn.Module you can read like prose. Afternoon: you subclass it, tweak the forward pass, and launch an ablation with your Chapter 11 harness. Evening: the validation curves tell you whether the idea works. Total new code: maybe 60 lines.
That speed — paper to tested idea in a day — is the real product of the PyTorch ecosystem. It compounds: every paper you can read as code becomes raw material, and every experiment you can run cleanly becomes evidence. Researchers in other frameworks can do all of this too, but they do it while translating: translating paper code from PyTorch, translating answers from PyTorch forums, translating pre-trained weights into their format. Translation is a tax on every idea. PyTorch removes the tax.
Pick any recent paper in your field with public code. Open its repository and find these four things: (1) the model class (nn.Module), (2) the loss function, (3) the optimizer construction, (4) the training loop. Time yourself. If you can find all four in under ten minutes, you already read PyTorch structurally — this book will just deepen that. If you can't, note where you got lost: those are exactly the chapters to focus on.
torchvision, Hugging Face, Lightning, experiment trackers) built on one foundation: tensors, autograd, modules, and the training loop.When you "install PyTorch," you are installing a Python package (plus its compiled math libraries) that can do fast tensor math. The single most important choice during installation is which version of the math backend you get: CPU-only, or CUDA-enabled for NVIDIA GPUs. PyTorch itself is the same Python code either way; what differs is whether operations like matrix multiplication run on your processor or on a graphics card with thousands of parallel cores.
This matters because neural network training is, at its heart, an enormous number of matrix multiplications. A GPU can do them 10 to 100 times faster than a CPU. For this book's small examples, a CPU is fine. For anything resembling real research — training on CIFAR, fine-tuning a transformer — a GPU changes the experience from "impossible" to "overnight run."
If you are a student without a GPU machine, start with Google Colab. It is a free Jupyter-notebook service from Google that gives you access to a GPU for limited hours per day — more than enough for this book and for small research experiments. There is nothing to install: PyTorch (with GPU support) comes pre-installed.
import torch
print("PyTorch version:", torch.__version__)
print("CUDA available:", torch.cuda.is_available())
print("GPU name:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else "none (CPU only)")
If torch.cuda.is_available() prints True, you have a working GPU. Colab's free tier gives you a modest GPU (often a T4) with usage limits — if you get a message about GPU unavailability, wait a few hours or continue on CPU; every example in this book runs on CPU too, just slower.
Colab tips that save real pain:
- Files you upload or create vanish when the runtime disconnects (typically after ~12 hours, or 90 minutes idle). Save anything important to Google Drive (mount it with the file-browser button) or download it.
- Install extra packages with !pip install <package> in a cell — they last for the session.
- Use Runtime → Restart and run all before sharing results, so you know the notebook works top-to-bottom.
If you have your own computer, install PyTorch with pip or conda. The official installer at pytorch.org generates the exact command for your system — use it rather than guessing, because the CUDA version must match your NVIDIA driver.
CPU-only (any laptop, no NVIDIA GPU):
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
With NVIDIA GPU (check your CUDA version with nvidia-smi first):
# Example for CUDA 12.x — confirm the exact command at pytorch.org for your setup
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
With conda (handles CUDA libraries for you):
conda install pytorch torchvision pytorch-cuda=12.1 -c pytorch -c nvidia
After installing, verify in Python:
import torch
print(torch.__version__) # e.g. 2.x.x
print(torch.cuda.is_available()) # True if GPU build works
A common student machine setup is: CPU-only PyTorch locally for writing and debugging code, Colab GPU for actual training runs. This is a perfectly respectable research workflow — many published experiments were trained on free Colab GPUs.
Here is the good news: almost nothing. PyTorch is designed so the same code runs on both; you only choose where tensors live. The pattern you will use in every chapter from here on:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("Using device:", device)
model = MyModel().to(device) # move the model to the device
x = torch.randn(32, 10).to(device) # move data to the same device
out = model(x) # computation happens on that device
Two rules that prevent 90% of device headaches:
1. The model and the data must be on the same device. A model on GPU with data on CPU raises an error — this is the single most common beginner crash (see Chapter 10).
2. Set the device once, at the top of your script, and use it everywhere. Never hard-code "cuda".
You can also move results back to the CPU (for plotting or saving) with .cpu(), and detach them from the computation graph with .detach() — both appear constantly in training loops:
loss_value = loss.item() # Python number, safe to log/plot
preds = outputs.detach().cpu() # tensor on CPU, no gradient tracking
GPU memory (VRAM) is the main limit you will hit. Rough guide for this book's level:
| Task | Typical VRAM needed |
|---|---|
| Toy examples in this book (small MLPs) | < 1 GB — any GPU, even CPU |
| CIFAR-10 with a small CNN | 2–4 GB |
| Fine-tuning a small transformer (e.g., BERT-base) | 8–12 GB |
| Training a transformer from scratch | 16 GB+ / multiple GPUs |
When you run out, you get CUDA out of memory. The standard fixes, in order: reduce batch size, use a smaller model, or use gradient accumulation (Chapter 12). Do not buy hardware for this book — Colab is enough.
PyTorch releases roughly twice a year. For research, the exact version matters less than recording it: always note the PyTorch version (and CUDA version) alongside your results. A result obtained on PyTorch 2.1 may differ in the fourth decimal place on 2.3 due to kernel changes — reviewers occasionally ask, and "I recorded it" is the entire difference between a painful week and a one-line answer. Chapter 11 makes this automatic.
For Your Research
Your environment is part of your method. Published papers increasingly include a "reproducibility" note or appendix listing software versions. Start the habit now: at the top of every experiment notebook or script, print and log
torch.__version__,torch.cuda.is_available(), and the GPU name. Better yet, export your environment once per project withpip freeze > requirements.txt(orconda env export > environment.yml) and commit it with your code. When a reviewer — or your future self — asks "what exactly did you run?", the answer should be a file, not a memory.
After installing, don't just check the version — run a script that exercises the whole stack (tensor math, GPU, and a tiny training step). Save this as check_install.py and run it once per machine:
import torch
print("torch:", torch.__version__)
print("cuda available:", torch.cuda.is_available())
if torch.cuda.is_available():
print("gpu:", torch.cuda.get_device_name(0))
print("capability:", torch.cuda.get_device_capability(0))
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# tensor math on the device
x = torch.randn(1000, 1000, device=device)
print("matmul ok:", (x @ x.T).shape)
# autograd + optimizer smoke test
w = torch.randn(10, 1, device=device, requires_grad=True)
opt = torch.optim.AdamW([w], lr=1e-2)
for _ in range(5):
opt.zero_grad()
loss = (w ** 2).sum()
loss.backward()
opt.step()
print("train step ok, loss:", loss.item())
print("ALL CHECKS PASSED")
If this runs clean, your environment can do everything in this book. If it fails, the failure location tells you exactly which layer is broken (import → install problem; CUDA lines → GPU build problem; optimizer lines → deeper issue).
torch.cuda.is_available() is False on a machine with an NVIDIA GPU. Almost always a CUDA/driver mismatch: the PyTorch build's CUDA version must be ≤ your driver's supported version (nvidia-smi shows the max supported CUDA at top-right). Fix: reinstall using the exact command from pytorch.org for your CUDA version.pip installs the wrong variant. The default pip install torch now usually picks correctly, but on some systems it grabs CPU-only while you wanted GPU (or vice versa). Fix: use the explicit --index-url from pytorch.org, and verify with the diagnostic script.conda create -n torch python=3.11 or python -m venv torch-env).!pip install (installs into the notebook's environment); locally, run python -c "import sys; print(sys.executable)" in both the terminal and the notebook and make them match.torch.device("mps"). Install the standard macOS build from pytorch.org, then use "mps" in your device selection (torch.backends.mps.is_available() to check). Most of this book works unchanged; a few exotic ops fall back to CPU automatically.DataLoader(num_workers > 0) requiring the if __name__ == "__main__": guard in scripts (Chapter 8/10).Free Colab is enough for this book, but real projects outgrow it. Know the ladder before you need it:
Whichever you use, the Chapter 9 + 11 habits (checkpoints to persistent storage, configs, logging) are what make cloud training survivable — a preempted spot instance without checkpoints is money burned.
Two mundane things that bite everyone once:
runs/ there for anything important; locally, keep a runs/ git-ignore and clean it monthly. A run you can't find is a run you didn't do.python -m venv or conda create -n <project> per project, with its own requirements.txt — Chapter 11's layout assumes this.pip uninstall torch torchvision) rather than layering installs — mixed-version leftovers are a classic source of phantom import errors that waste an afternoon.Colab's two harshest realities are the idle timeout (~90 minutes) and the 12-hour runtime limit — either can vaporize unsaved work. The defense is one cell at the top of every important notebook:
from google.colab import drive
drive.mount('/content/drive')
import os
WORK = "/content/drive/MyDrive/pytorch-book"
os.makedirs(f"{WORK}/checkpoints", exist_ok=True)
os.makedirs(f"{WORK}/runs", exist_ok=True)
print("work dir:", WORK)
Then point all saves at WORK: checkpoints, metrics CSVs, configs. The Chapter 9 last.pt pattern matters doubly here — set it to save every epoch into Drive, so a disconnect costs you at most one epoch. Two more Colab habits: (1) Runtime → Restart and run all before trusting any result (out-of-order cell execution is a classic source of "it worked yesterday" mysteries); (2) keep a notes.md in the Drive folder logging what each notebook run was for — Colab notebooks proliferate and their names lie.
device once, move model and data with .to(device), and keep them on the same device.A tensor is PyTorch's fundamental object: a multi-dimensional array of numbers. If you know NumPy, you already understand 90% of it — a PyTorch tensor is like a NumPy array with two superpowers: it can live on a GPU, and it can track operations for automatic differentiation (Chapter 4).
The word "tensor" sounds intimidating, but the idea is simple. You already know the ranks:

In deep learning, nearly everything is a tensor: your input data, the model's weights, the gradients, the outputs. Learning to think in tensors — and especially in shapes — is the single most transferable skill in this book. Most PyTorch errors are shape errors, and most shape errors come from not tracking what each dimension means.
import torch
# From Python data
t1 = torch.tensor([1.0, 2.0, 3.0]) # 1-D, shape (3,)
t2 = torch.tensor([[1, 2], [3, 4]]) # 2-D, shape (2, 2)
# Standard initializations
z = torch.zeros(2, 3) # all zeros, shape (2, 3)
o = torch.ones(2, 3) # all ones
r = torch.rand(2, 3) # uniform random in [0, 1)
rn = torch.randn(2, 3) # standard normal (mean 0, std 1) — the workhorse
eye = torch.eye(3) # 3x3 identity matrix
seq = torch.arange(0, 10, 2) # like Python range: [0, 2, 4, 6, 8]
Two creation functions deserve special attention because they appear in almost every research script:
torch.randn(...) — random numbers from a standard normal distribution. This is how weights are initialized (Chapter 5) and how noise is sampled in generative models.torch.zeros(...) / torch.ones(...) — how you pre-allocate accumulators, masks, and counters.Every tensor has three attributes you should check constantly while learning:
x = torch.randn(32, 10)
print(x.shape) # torch.Size([32, 10]) — the dimensions
print(x.dtype) # torch.float32 — the number type
print(x.device) # cpu (or cuda:0) — where it lives
dtype matters more than beginners expect. The default floating-point type is float32 — the standard for deep learning (a good balance of precision and speed/memory). Common pitfalls: creating a tensor from Python integers gives int64, and mixing int64 with float32 in an operation raises an error. When in doubt, create floats explicitly (torch.tensor([1.0, 2.0])) or convert with .float(). Model weights are float32; labels for classification are int64 (more on this in Chapter 6 — it is a deliberate convention, not an accident).
Tensor indexing works like NumPy's, and you will use it constantly to inspect data and build masks:
x = torch.arange(12).reshape(3, 4)
# tensor([[ 0, 1, 2, 3],
# [ 4, 5, 6, 7],
# [ 8, 9, 10, 11]])
print(x[0]) # first row: tensor([0, 1, 2, 3])
print(x[:, 1]) # second column: tensor([1, 5, 9])
print(x[1:3, :2]) # rows 1-2, cols 0-1
print(x[x > 5]) # boolean mask: tensor([ 6, 7, 8, 9, 10, 11])
Boolean masking (x[x > 5]) is worth practicing: it is how you filter predictions, select hard examples, and implement many research ideas (like focusing loss on misclassified samples) in one line.
Reshaping tensors is something you will do in nearly every model. The key operations:
x = torch.arange(12) # shape (12,)
m = x.reshape(3, 4) # shape (3, 4) — same data, new shape
m2 = x.view(3, 4) # like reshape, but requires contiguous memory
t = m.T # transpose: shape (4, 3)
flat = m.reshape(-1) # -1 means "infer this dimension": shape (12,)
batched = x.reshape(2, 2, 3) # shape (2, 2, 3)
reshape vs view: both change shape without copying data when possible. view fails if the tensor's memory layout doesn't allow it; reshape falls back to copying. Rule of thumb: use reshape unless you have a reason to demand no-copy (view), and you will rarely go wrong.
Three more shape tools that appear everywhere in real code:
x = torch.randn(32, 10) # a batch of 32 samples, 10 features each
x2 = x.unsqueeze(0) # add a dim at front: (1, 32, 10)
x3 = x.unsqueeze(-1) # add a dim at end: (32, 10, 1)
back = x3.squeeze(-1) # remove size-1 dims: (32, 10)
cat = torch.cat([x, x], dim=0) # concatenate: (64, 10)
stack = torch.stack([x, x]) # stack as new dim: (2, 32, 10)
Learn the difference between cat and stack cold: cat glues along an existing dimension (two batches become one bigger batch), stack creates a new dimension (a list of batches becomes one batched tensor). Confusing them is a classic shape bug.
Broadcasting is PyTorch's rule for operating on tensors of different shapes: dimensions are aligned from the right, and any dimension of size 1 (or a missing dimension) is stretched to match. Examples:
a = torch.ones(3, 4)
b = torch.ones(4) # shape (4,) aligns with last dim -> result (3, 4)
c = a + b # each row of a gets b added
m = torch.ones(3, 1)
n = torch.ones(1, 4)
print((m + n).shape) # (3, 4) — both stretched
Broadcasting is why you can write x - mean to normalize a whole batch by one mean vector, or add a bias vector to every row of a batch. It is elegant — and it is also the source of bugs where shapes almost match and PyTorch silently does something you didn't intend. The defense is a habit: whenever a result looks wrong, print shapes first. A one-line print(x.shape) has solved more deep learning bugs than any debugger.
Element-wise ops work as you'd expect (+, -, *, /, **, comparisons). The two matrix operations to never confuse:
a = torch.randn(3, 4)
b = torch.randn(4, 5)
elem = a * a # element-wise: (3, 4)
mat = a @ b # matrix multiplication: (3, 5)
mat2 = torch.matmul(a, b) # same as @
@ is matrix multiplication; * is element-wise. A neural network layer is essentially @ plus a bias — so this distinction is deep learning arithmetic.
Reductions collapse dimensions and are how losses and metrics are computed:
x = torch.randn(32, 10)
print(x.sum()) # single number: sum of everything
print(x.mean(dim=0).shape) # (10,) — mean over the batch: per-feature means
print(x.max(dim=1)) # values and indices of max per row
print(x.argmax(dim=1).shape) # (32,) — predicted class per sample (classification!)
argmax(dim=1) deserves a mental bookmark: "for each sample in the batch, which class scored highest" is exactly how you turn model outputs into predictions (Chapter 7).
x = torch.randn(100, 50)
if torch.cuda.is_available():
x = x.to("cuda") # or x.cuda()
y = x @ x.T # runs on GPU
x = x.to("cpu") # or x.cpu()
# NumPy interop (CPU tensors only) — shares memory, no copy!
import numpy as np
arr = np.array([1.0, 2.0, 3.0])
t = torch.from_numpy(arr) # tensor shares memory with arr
back = t.numpy() # back to NumPy, also shared
The memory-sharing is a double-edged sword: modifying one modifies the other. It is efficient (no copies), but be deliberate about it. Also note: .numpy() only works on CPU tensors — move to CPU first.
Here is a realistic mini-task — standardizing features (zero mean, unit variance), written purely with tensor ops:
torch.manual_seed(0)
data = torch.randn(1000, 5) * 10 + 3 # 1000 samples, 5 features, arbitrary scale
mean = data.mean(dim=0) # (5,) — per-feature mean
std = data.std(dim=0) # (5,) — per-feature std
normalized = (data - mean) / std # broadcasting over the batch dim
print(normalized.mean(dim=0)) # ≈ [0, 0, 0, 0, 0]
print(normalized.std(dim=0)) # ≈ [1, 1, 1, 1, 1]
Three lines, no loops. This "think in whole tensors, not loops" style is the core performance habit of PyTorch: loops over samples in Python are slow; tensor ops run in optimized C++/CUDA. Whenever you catch yourself writing for i in range(batch_size), stop and look for the tensor op — it almost always exists.
For Your Research
Shape discipline is paper discipline. Every tensor in your experiment has a meaning per dimension:
(batch, channels, height, width)for images,(batch, sequence, features)for sequences. Write these conventions in a comment at the top of your data pipeline and model — e.g.,# x: (B, C, H, W). Reviewers and collaborators read shape conventions like notation in a math paper; inconsistent or undocumented shapes are the code equivalent of undefined symbols. When you later write your paper's method section, these shape annotations become your equations' dimensions almost verbatim.
Tensors have a hidden property: memory layout — the order elements are stored in. A freshly created (3, 4) tensor stores its 12 numbers row by row (row-major, "contiguous"). reshape and view only avoid copying when the new shape is compatible with that layout. Transposing scrambles the layout:
x = torch.arange(12).reshape(3, 4)
print(x.is_contiguous()) # True
t = x.T
print(t.is_contiguous()) # False — columns are now strided
# t.view(12) # RuntimeError: view size is not compatible
print(t.reshape(12).shape) # (12,) — reshape copies silently and works
print(t.contiguous().view(12).shape) # (12,) — explicit copy, then view works
Practical rule: use reshape by default (it does the right thing either way). Reach for view only when you specifically need the no-copy guarantee, and call .contiguous() first when a view fails after a transpose or permute. This is one of those errors that looks terrifying ("view size is not compatible with input tensor's size and stride") but has a one-word fix once you understand layout.
Appending _ to an op makes it in-place (relu_(), add_(), mul_()), modifying the tensor instead of allocating a new one. This saves memory — meaningful for giant models — but has two costs: (1) it breaks autograd if the tensor is needed for gradient computation (Chapter 10, Error 8), and (2) it makes debugging harder because values are destroyed. Guidance: never use in-place ops while developing; consider them only as a late optimization for memory-bound models, and never on tensors that require gradients.
Three more indexing tools that appear constantly in research code:
x = torch.randn(4, 5)
# torch.where: element-wise if-else — enormously useful
y = torch.where(x > 0, x, torch.zeros_like(x)) # = ReLU, written manually
# masked_fill: set masked positions to a value (attention mechanisms live on this)
mask = torch.tensor([True, False, True, False, True])
row = torch.randn(5)
print(row.masked_fill(mask, float("-inf"))) # masked positions become -inf
# gather: pick one element per row according to indices (used in many losses)
logits = torch.randn(4, 10)
labels = torch.tensor([3, 0, 9, 1])
picked = logits.gather(1, labels.unsqueeze(1)) # shape (4, 1): the logit of the true class
gather deserves a second look: "for each sample, get the score of its true class" is the inner operation of cross-entropy loss itself. When you read advanced loss implementations, you'll find gather doing the precise selection work.
Deep learning overwhelmingly uses float32 (the default). float64 doubles memory for no accuracy benefit in almost all cases — don't use it. float16/bfloat16 (half precision) halve memory and speed up modern GPUs, but need care (loss scaling) — that's Chapter 12's mixed-precision topic. For this book: stay in float32 unless you have a measured reason to leave.
shape, dtype, and device; print them whenever something looks wrong.reshape/view, unsqueeze/squeeze, cat/stack are your daily shape tools — learn cat vs stack precisely.@ is matrix multiplication, * is element-wise — never confuse them; think in whole-tensor ops, not Python loops..to(device).Training a neural network means adjusting millions of numbers (weights) to make the loss go down. To know how to adjust each weight, you need the gradient: how much would the loss change if this weight changed a tiny bit? Computing gradients for millions of weights by hand is impossible; computing them by finite differences (nudge each weight, re-run, measure) would take millions of forward passes.
Autograd — automatic differentiation — is PyTorch's solution. You write the forward computation normally, and PyTorch silently records every operation in a computation graph. When you call .backward(), it walks that graph in reverse, applying the chain rule from calculus, and fills in every gradient in a single pass. One forward pass, one backward pass, all gradients. This is backpropagation, automated.
The beautiful part: you never write derivative code. You only write the forward math — the thing you understand — and the framework handles the calculus.

import torch
x = torch.tensor(2.0, requires_grad=True) # "track operations on x"
y = x ** 2 + 3 * x + 1 # y = x² + 3x + 1; graph recorded
print(y) # tensor(11., grad_fn=<AddBackward0>)
y.backward() # compute dy/dx via chain rule
print(x.grad) # tensor(7.) — because dy/dx = 2x + 3 = 7 at x=2
Three things happened:
1. requires_grad=True told PyTorch to record operations involving x.
2. Computing y built a graph of operations (pow, mul, add) behind the scenes — note grad_fn in the printout, which is the graph node.
3. y.backward() traversed the graph backward, computing the gradient of y with respect to x and storing it in x.grad.
Check the math: y = x² + 3x + 1, so dy/dx = 2x + 3. At x = 2: 4 + 3 = 7. ✓ The framework did calculus correctly, and it will do it correctly for a graph with a million nodes too.
.backward() computes gradients of a scalar output. If your output is a vector, you must reduce it to a scalar first (usually with .sum()), or pass explicit gradient weights. In practice this is never a problem: loss functions always produce a scalar (one number summarizing "how wrong"), so you always call loss.backward().
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x ** 2
# y.backward() # ERROR: y is not a scalar
y.sum().backward() # fine: gradient of sum(x²)
print(x.grad) # tensor([2., 4., 6.]) — d(x²)/dx = 2x
This is where Chapter 1's "dynamic graph" pays off. The graph is created fresh on each forward pass, so it can depend on data, loops, and conditions:
x = torch.tensor(2.0, requires_grad=True)
y = x
for _ in range(3):
y = y * x + 1 # loop unrolled into the graph, 3 iterations
y.backward()
print(x.grad) # correct gradient through the loop — try verifying by hand!
Try doing that with a static graph framework's old API — you would be fighting the framework. In PyTorch it is just Python. This dynamic nature is exactly what lets researchers invent architectures with data-dependent control flow (like attention mechanisms that loop over variable-length sequences).
You can inspect the graph if you're curious — each tensor with a grad_fn points to the operation that created it, which points to its inputs:
x = torch.tensor(2.0, requires_grad=True)
y = x ** 2
z = y + 1
print(z.grad_fn) # <AddBackward0>
print(z.grad_fn.next_functions) # shows the PowBackward node feeding it
You will rarely inspect graphs manually, but knowing they exist — and are rebuilt per forward pass — explains several behaviors below.
Gradients accumulate. Calling .backward() adds to .grad; it does not overwrite. This is deliberate (it enables training on large effective batches by accumulating over mini-batches), but it means in a training loop you must zero gradients each step:
x = torch.tensor(2.0, requires_grad=True)
for step in range(3):
x.grad = None # or optimizer.zero_grad() in real code
y = x ** 2
y.backward()
print(f"step {step}: grad = {x.grad.item()}") # 4.0 every time
Without the zeroing line, you'd see 4.0, 8.0, 12.0 — the classic "my loss explodes / behaves weirdly" bug. Burn this into memory: zero grads, forward, backward, step — the four-beat rhythm of every training loop (Chapter 7).
(In modern code you will call optimizer.zero_grad(), which does exactly this for all model parameters.)
Not every computation needs gradients. During evaluation (measuring accuracy on test data), tracking gradients wastes memory and time. Two tools:
x = torch.tensor(2.0, requires_grad=True)
with torch.no_grad(): # nothing inside is tracked
y = x ** 2
print(y.requires_grad) # False
z = (x ** 2).detach() # detach: cut this tensor out of the graph
print(z.requires_grad) # False
torch.no_grad() — a context manager: "don't build graphs in here." Use it around all evaluation/inference code..detach() — returns a tensor sharing the same data but cut off from the graph. Use it when you need the values (for logging, plotting, metrics) without the gradients.A subtle one: torch.no_grad() is also how you do manual weight updates safely (otherwise the update itself gets tracked and the graph grows forever — a real memory-leak bug).
In a neural network, you set requires_grad=True on the weights (the things you want to learn), not on the input data:
w = torch.randn(3, requires_grad=True) # weights: learn these
b = torch.tensor(0.0, requires_grad=True)
x = torch.tensor([1.0, 2.0, 3.0]) # data: no grad needed
pred = (w * x).sum() + b
loss = (pred - 10.0) ** 2
loss.backward()
print(w.grad, b.grad) # gradients for w and b; x.grad is None
Note x.grad is None — PyTorch only accumulates gradients into leaf tensors with requires_grad=True (tensors you created directly, not results of operations). Intermediate values' gradients are computed during the backward pass but discarded unless you ask to keep them (tensor.retain_grad() — occasionally useful when debugging).
Let's tie it together — linear regression with explicit autograd, no nn.Module yet (that's Chapter 5). This is the smallest possible "training":
import torch
torch.manual_seed(0)
# True relationship: y = 3x + 2, plus noise
x = torch.rand(100, 1) * 10
y = 3 * x + 2 + torch.randn(100, 1)
w = torch.randn(1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
lr = 0.01
for epoch in range(200):
w.grad = None; b.grad = None # 1. zero grads
pred = w * x + b # 2. forward
loss = ((pred - y) ** 2).mean() # 3. scalar loss (MSE)
loss.backward() # 4. backward: gradients into w.grad, b.grad
with torch.no_grad(): # 5. update weights (no tracking!)
w -= lr * w.grad
b -= lr * b.grad
print(f"learned: w={w.item():.3f}, b={b.item():.3f}") # ≈ w=3.0, b=2.0
print(f"final loss: {loss.item():.4f}")
Run it: w converges to ~3 and b to ~2. You just did machine learning with five lines of loop. Every training loop in this book — and in published research — is this same pattern with bigger models and better tooling: zero grads → forward → loss → backward → step. When training code confuses you later, come back to this example and find the five steps.
argmax, or converting to a Python int mid-graph) break the chain — gradients won't flow through them. (This is why classification uses softmax + cross-entropy instead of differentiating through "pick the max.")x += 1) can corrupt the graph — PyTorch will raise an error rather than give wrong gradients. Prefer out-of-place ops (x = x + 1).For Your Research
Autograd is why you can invent new layers. Any differentiable computation you can write in PyTorch becomes trainable automatically. When you read a paper proposing a novel layer or loss, the authors did not derive gradients by hand — they wrote the forward computation and let autograd handle the rest. This has a practical consequence for your own work: when you have an idea for a custom loss or architecture tweak, prototype the forward version first and check that
loss.backward()runs and gradients are non-zero/non-NaN. If the forward is correct and differentiable, the backward is free. Many "novel" paper contributions are exactly this: a clever forward computation plus autograd.
The chain rule is the entire mathematical content of backpropagation, and it's worth seeing once with concrete numbers. Take y = (2x + 1)² at x = 3. Break it into steps: a = 2x + 1 = 7, then y = a² = 49. The chain rule says: dy/dx = (dy/da) × (da/dx) = 2a × 2 = 14 × 2 = 28.
Autograd does exactly this, mechanically, for millions of chained steps:
x = torch.tensor(3.0, requires_grad=True)
a = 2 * x + 1 # a = 7, da/dx = 2
y = a ** 2 # y = 49, dy/da = 2a = 14
y.backward()
print(x.grad) # 28.0 = 14 * 2 ✓
Every .backward() call is this same multiplication of local derivatives, chained from the loss back to every weight. There is no magic — just the chain rule applied at a scale no human could do by hand. When someone says "backprop," they mean this walk; when they say "autograd does backprop," they mean PyTorch builds the chain for you.
By default, only leaf tensors (like weights) keep their .grad; intermediate gradients are freed during the backward pass to save memory. If you're debugging and want to see an intermediate's gradient, call retain_grad() before backward:
x = torch.tensor(2.0, requires_grad=True)
h = (x ** 2).retain_grad() if False else x ** 2 # placeholder — see below
More precisely:
x = torch.tensor(2.0, requires_grad=True)
h = x ** 2
h.retain_grad() # ask to keep h's gradient
y = h ** 3
y.backward()
print(h.grad) # d(h³)/dh = 3h² = 3*16 = 48
PyTorch can also differentiate through a backward pass (create_graph=True), giving second derivatives — used in meta-learning and some optimization research. You won't need it in this book, but knowing it exists demystifies papers that mention "higher-order gradients": it's the same machinery, applied twice.
Rarely, you need an operation autograd doesn't know how to differentiate (a novel activation, a custom CUDA kernel). PyTorch lets you define both directions explicitly:
class MyReLU(torch.autograd.Function):
@staticmethod
def forward(ctx, x):
ctx.save_for_backward(x) # stash what backward needs
return x.clamp(min=0)
@staticmethod
def backward(ctx, grad_out):
(x,) = ctx.saved_tensors
return grad_out * (x > 0).float() # gradient flows only where x > 0
x = torch.tensor([-1.0, 2.0], requires_grad=True)
y = MyReLU.apply(x).sum()
y.backward()
print(x.grad) # tensor([0., 1.]) ✓ — correct ReLU gradients
You will likely never write one of these in your first year — but when you read a paper whose repository contains a functions.py with autograd.Function subclasses, you'll now recognize exactly what it's doing: hand-specifying the forward and its derivative for an operation the authors invented.
.backward() applies the chain rule to fill in all gradients at once.requires_grad=True marks what to track (usually weights); .backward() requires a scalar output (your loss).optimizer.zero_grad()), or training silently breaks.torch.no_grad() for evaluation and .detach() when you need values without gradients.In Chapter 4 you trained a linear model with hand-managed tensors w and b. That works for two parameters. It does not work for two million. Real networks need three things hand-rolled tensors don't provide: organized parameters (so an optimizer can find and update all of them), composable layers (so you can stack building blocks), and behavior modes (training vs. evaluation, which affects layers like dropout).
torch.nn.Module is the base class that provides all three. Everything in PyTorch — a single linear layer, a ResNet, a transformer — is an nn.Module. Learning this one class deeply is learning the framework's entire modeling system.
An nn.Module subclass has two parts you write, and machinery you inherit:
import torch
import torch.nn as nn
class TinyNet(nn.Module):
def __init__(self):
super().__init__() # always call this first
self.fc1 = nn.Linear(10, 32) # layer: 10 inputs -> 32 outputs
self.relu = nn.ReLU() # activation function
self.fc2 = nn.Linear(32, 1) # layer: 32 inputs -> 1 output
def forward(self, x):
x = self.fc1(x)
x = self.relu(x)
x = self.fc2(x)
return x
model = TinyNet()
print(model)
__init__ declares the sub-modules (layers); forward describes how data flows through them. You never call model.forward(x) directly — you call model(x). The __call__ machinery does important bookkeeping around your forward (hooks, mode flags), so always call the module, never forward.
nn.Linear(in_features, out_features) is the workhorse layer: it holds a weight matrix of shape (out_features, in_features) and a bias vector of shape (out_features), and computes x @ W.T + b. Its weights are initialized sensibly by default (a uniform distribution scaled by layer size — a variant of the Xavier/Glorot initialization from reference [9]).
Every tensor inside a module that should be learned is a parameter. Modules track them automatically:
model = TinyNet()
for name, p in model.named_parameters():
print(name, p.shape, p.requires_grad)
# fc1.weight torch.Size([32, 10]) True
# fc1.bias torch.Size([32]) True
# fc2.weight torch.Size([1, 32]) True
# fc2.bias torch.Size([1]) True
n_params = sum(p.numel() for p in model.parameters())
print("total parameters:", n_params) # 32*10+32 + 1*32+1 = 385
model.parameters() is what you hand to the optimizer (Chapter 6): "these are the numbers to adjust." Counting parameters with the one-liner above is a habit worth forming — papers report parameter counts, and yours should match your claims.
If you need a custom learnable tensor (not inside a standard layer), wrap it in nn.Parameter:
class ScaleShift(nn.Module):
def __init__(self):
super().__init__()
self.scale = nn.Parameter(torch.ones(1)) # learnable!
self.shift = nn.Parameter(torch.zeros(1)) # learnable!
def forward(self, x):
return x * self.scale + self.shift
A plain torch.tensor assigned as an attribute would not be registered — the optimizer would silently ignore it. This is a real bug researchers hit: "my custom parameter isn't learning." The fix is always nn.Parameter.
Without activations, stacking linear layers is pointless — a stack of linear operations collapses into one linear operation, and the network could only ever learn straight-line relationships. Activation functions apply a non-linearity after each layer, which is what lets networks learn curves, boundaries, and complex patterns.
The ones you need first:
relu = nn.ReLU() # max(0, x) — the default hidden-layer activation
sigmoid = nn.Sigmoid() # 1/(1+e^-x) — squashes to (0, 1); binary outputs
tanh = nn.Tanh() # squashes to (-1, 1); older hidden activation
softmax = nn.Softmax(dim=1) # turns scores into probabilities summing to 1
ReLU (max(0, x)) is the standard for hidden layers: simple, fast, and it avoids the vanishing-gradient problems of sigmoid/tanh in deep stacks. You will use it 95% of the time in this book. Softmax appears at classification outputs (with cross-entropy loss, Chapter 6). Sigmoid appears for binary decisions and gating.
For a plain stack of layers, nn.Sequential is cleaner:
model = nn.Sequential(
nn.Linear(10, 32),
nn.ReLU(),
nn.Linear(32, 16),
nn.ReLU(),
nn.Linear(16, 1),
)
out = model(torch.randn(8, 10)) # batch of 8 -> (8, 1)
print(out.shape)
Use Sequential for simple stacks; write a custom Module class when you need branches, skip connections, multiple inputs/outputs, or any logic in forward. Research models are almost always custom classes — ResNet's skip connections can't be expressed as a plain sequence.
Let's build a complete small classifier — two-moons-style data, a 2-layer network, trained with the Chapter 4 loop pattern. (Chapters 6–7 will give this proper tooling; here the goal is to see nn.Module in action.)
import torch
import torch.nn as nn
torch.manual_seed(42)
# --- synthetic 2-D binary classification data: two blobs ---
n = 200
blob0 = torch.randn(n, 2) * 0.8 + torch.tensor([-2.0, 0.0])
blob1 = torch.randn(n, 2) * 0.8 + torch.tensor([2.0, 0.0])
X = torch.cat([blob0, blob1]) # (400, 2)
y = torch.cat([torch.zeros(n, 1), torch.ones(n, 1)]) # (400, 1)
class Classifier(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(2, 16), nn.ReLU(),
nn.Linear(16, 16), nn.ReLU(),
nn.Linear(16, 1), # single logit output
)
def forward(self, x):
return self.net(x)
model = Classifier()
opt = torch.optim.Adam(model.parameters(), lr=0.01)
loss_fn = nn.BCEWithLogitsLoss() # binary classification loss (Chapter 6)
for epoch in range(300):
opt.zero_grad()
logits = model(X) # (400, 1)
loss = loss_fn(logits, y)
loss.backward()
opt.step()
if epoch % 100 == 0:
print(f"epoch {epoch}: loss={loss.item():.4f}")
with torch.no_grad():
preds = (torch.sigmoid(model(X)) > 0.5).float()
acc = (preds == y).float().mean()
print(f"train accuracy: {acc.item():.3f}") # expect ~1.000 on this easy data
Notice how the pieces slot together: nn.Module holds the architecture and parameters, model.parameters() feeds the optimizer, model(X) runs the forward pass, and the Chapter 4 rhythm (zero → forward → loss → backward → step) drives learning. You now have the complete vocabulary of a PyTorch training script.
Modules have two modes, switched with model.train() and model.eval(). They matter for layers whose behavior differs between training and inference — dropout (randomly zeroes activations during training only) and batch normalization (uses batch statistics in training, running statistics in eval). Our tiny nets above don't use these layers, so the modes change nothing yet — but the habit must be built now:
model.train() # before the training loop
# ... training ...
model.eval() # before evaluation — and combine with torch.no_grad()
Forgetting model.eval() before measuring test accuracy is a classic source of "my test results are noisy / don't reproduce." Chapter 7 makes it automatic; Chapter 10 explains the failure.
For Your Research
Your model class is the executable version of your paper's method section. When reviewers read "we use a 3-layer MLP with 128 hidden units and ReLU activations," they will check your
nn.Moduleand expect to find exactly that. Write model classes that mirror your paper's description one-to-one: name layers after the paper's notation where possible, and put the architectural hyperparameters (hidden sizes, depth, dropout rates) in__init__arguments rather than hard-coding them. A model class whose constructor readsMyModel(hidden_dim=128, num_layers=3, dropout=0.1)is self-documenting, easy to ablate (Chapter 11), and trivially maps to a methods paragraph.
nn.Linear doesn't start with random junk — it uses a scaled uniform distribution (Kaiming/He-style for the default). This matters enormously: initialize too large and activations explode through deep stacks; too small and signals vanish. The defaults are good, but you should know how to take control:
import torch.nn as nn
layer = nn.Linear(64, 32)
nn.init.kaiming_uniform_(layer.weight, nonlinearity="relu") # matched to ReLU
nn.init.zeros_(layer.bias)
# Xavier/Glorot (from reference [9]) — matched to tanh/sigmoid:
# nn.init.xavier_uniform_(layer.weight)
The principle (Glorot & Bengio [9]; He et al. for ReLU): keep the variance of activations stable as signals pass through layers. If you ever build a custom layer or see "my deep network won't train at all," initialization is on the suspect list — and the one-line fix is using the initializer matched to your activation.
Two layers that make model.train() / model.eval() actually matter:
model = nn.Sequential(
nn.Linear(64, 64),
nn.BatchNorm1d(64), # normalizes activations using batch statistics
nn.ReLU(),
nn.Dropout(0.3), # randomly zeroes 30% of activations (train only)
nn.Linear(64, 10),
)
model.train() # Dropout active; BatchNorm uses current batch stats
model.eval() # Dropout off; BatchNorm uses learned running stats
The classic beginner bug is evaluating with model.train() still on: dropout randomness makes predictions noisy and BatchNorm uses single-batch stats — your "test accuracy" becomes a random number. The eval ritual from Chapter 7 exists for exactly these layers.
When fine-tuning a pre-trained model (Chapter 9/12), you often freeze early layers (keep their learned features) and train only the head:
from torchvision.models import resnet18, ResNet18_Weights
model = resnet18(weights=ResNet18_Weights.DEFAULT)
for p in model.parameters():
p.requires_grad = False # freeze everything...
model.fc = nn.Linear(model.fc.in_features, 10) # ...except a new head
# (the new layer's parameters default to requires_grad=True)
opt = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3)
requires_grad = False removes those parameters from the graph — no gradients computed, no updates, less memory. The optimizer only sees the unfrozen head. This pattern — frozen backbone, trained head — is behind a huge fraction of applied deep learning papers, and it's five lines once you understand parameters.
Not everything a module holds should be learned. BatchNorm's running mean/variance, for example, are updated during training but not by gradient descent — they're buffers, registered with register_buffer:
class MyNorm(nn.Module):
def __init__(self, n):
super().__init__()
self.weight = nn.Parameter(torch.ones(n)) # learned by optimizer
self.register_buffer("running_mean", torch.zeros(n)) # updated manually, saved in state_dict
def forward(self, x):
if self.training:
batch_mean = x.mean(dim=0)
self.running_mean.lerp_(batch_mean, 0.1) # moving average update
return (x - batch_mean) * self.weight
return (x - self.running_mean) * self.weight
Key properties of buffers: they appear in state_dict (so they save/load with the model), they move with .to(device), but the optimizer ignores them. When you read library code and see register_buffer, that's the signal: persistent state, not learned weights. A common example you'll meet: positional encodings in transformers are often buffers.
Need to initialize (or modify) every layer of a deep network at once? model.apply(fn) recursively visits every submodule:
def init_weights(m):
if isinstance(m, nn.Linear):
nn.init.kaiming_uniform_(m.weight, nonlinearity="relu")
nn.init.zeros_(m.bias)
model = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 5))
model.apply(init_weights) # visits every Linear, including nested ones
This is how research code applies custom initialization, freezes/unfreezes layer types, or converts layers across a whole model in one shot. Combined with named_parameters() filtering (Chapter 5's freezing pattern), it gives you complete programmatic control over large architectures.
A nasty gotcha: submodules in a plain Python list are invisible to the module system — their parameters won't appear in model.parameters(), won't move with .to(device), and won't save in the state_dict:
class Broken(nn.Module):
def __init__(self):
super().__init__()
self.layers = [nn.Linear(10, 10) for _ in range(3)] # BUG: plain list!
class Fixed(nn.Module):
def __init__(self):
super().__init__()
self.layers = nn.ModuleList([nn.Linear(10, 10) for _ in range(3)]) # registered
print(sum(p.numel() for p in Broken().parameters())) # 0 — nothing to train!
print(sum(p.numel() for p in Fixed().parameters())) # 330 — all registered
nn.ModuleList (ordered) and nn.ModuleDict (named) exist for exactly this: use them whenever layers are built in a loop or stored in a container. The symptom of the bug — "loss never decreases, gradients are None" — is maddening until you've seen it once; now you have.
nn.Module is the universal base class: declare layers in __init__, data flow in forward, and always call model(x), never model.forward(x).model.parameters() exposes everything learnable — hand it to the optimizer; count parameters with sum(p.numel() for p in model.parameters()).nn.Parameter, or the optimizer will silently ignore them.nn.Sequential for plain stacks; custom classes for anything with branches or skip connections.model.train() / model.eval() habit now — it prevents real evaluation bugs later.Every training run is driven by two choices you make before the loop starts:
Choose the loss for the task; choose the optimizer for the training dynamics. This chapter gives you the standard pairings, the reasoning behind them, and the decision tables for your dashboard.
Regression (predicting a number): Mean Squared Error
loss_fn = nn.MSELoss()
loss = loss_fn(predictions, targets) # mean((pred - target)^2)
MSE penalizes large errors quadratically — being off by 4 is sixteen times worse than being off by 1. That makes it sensitive to outliers; if your targets have extreme values, consider nn.L1Loss (mean absolute error, robust to outliers) or nn.SmoothL1Loss (a blend: quadratic near zero, linear far away — the default in many detection papers).
Binary classification (yes/no): BCEWithLogitsLoss
loss_fn = nn.BCEWithLogitsLoss() # input: raw logits (N, 1); target: 0/1 floats (N, 1)
loss = loss_fn(model(X), y.float())
The name says it all: binary cross-entropy, computed from logits (raw scores, no sigmoid applied). It applies the sigmoid internally in a numerically stable way. Do not apply sigmoid yourself before this loss — that double-squashes and silently degrades training. (Chapter 10's error list includes this one.)
Multi-class classification (which of K classes): CrossEntropyLoss
loss_fn = nn.CrossEntropyLoss() # input: logits (N, K); target: class indices (N,) as int64
loss = loss_fn(model(X), y_long) # y contains 0..K-1, NOT one-hot, NOT probabilities
This is the most-used loss in deep learning, and the most misunderstood by beginners. Three facts to memorize:
1. Input is raw logits — no softmax beforehand (like BCE, softmax is applied internally for stability).
2. Targets are class indices (torch.long), shape (N,) — not one-hot vectors, not floats.
3. It combines LogSoftmax + negative log-likelihood: it rewards the model for assigning high probability to the correct class.
If your targets are one-hot encoded, either convert with y.argmax(dim=1) or use nn.NLLLoss with a manual LogSoftmax — but the standard pipeline is indices + CrossEntropyLoss.
The decision table:
| Task | Output shape | Target format | Loss |
|---|---|---|---|
| Regression | (N, 1) or (N,) |
floats | nn.MSELoss (or L1Loss/SmoothL1Loss) |
| Binary classification | (N, 1) logits |
float 0/1, (N, 1) |
nn.BCEWithLogitsLoss |
| Multi-class (K classes) | (N, K) logits |
int64 indices (N,) |
nn.CrossEntropyLoss |
| Multi-label (several tags) | (N, K) logits |
float 0/1 (N, K) |
nn.BCEWithLogitsLoss |
The optimizer implements the update rule. You create it with the model parameters and a learning rate, then call zero_grad() / step() around the backward pass:
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
optimizer.zero_grad() # clear old gradients
loss.backward() # compute new gradients
optimizer.step() # update weights: w -= lr * (something like gradient)
SGD (stochastic gradient descent) — the original, from Robbins & Monro [11]:
opt = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4)
Plain SGD steps directly downhill. Momentum (typically 0.9) adds a "velocity" that smooths out noisy mini-batch gradients and speeds through ravines — almost always enabled. Weight decay adds a gentle pull toward small weights (L2 regularization), fighting overfitting. SGD + momentum remains the choice for many vision papers (ResNets were trained this way [6]) because it often generalizes slightly better, though it needs more learning-rate tuning.
Adam — the default starting point (Kingma & Ba [2]):
opt = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
Adam keeps a per-parameter learning rate: it adapts using running averages of the gradient (momentum-like) and of the squared gradient (scaling down updates for frequently-large gradients). In practice: Adam converges faster and is far less sensitive to the initial learning rate, which is why it's the default for most new projects, transformers, and quick experiments. Its downsides: slightly worse generalization than well-tuned SGD in some vision tasks, and the original Adam interacts oddly with L2 regularization — which is why AdamW (Loshchilov & Hutter [10]) exists and is now the standard Adam variant: it decouples weight decay from the adaptive update. Rule of thumb: use AdamW instead of Adam for new work.
The decision table:
| Situation | Optimizer | Typical LR | Notes |
|---|---|---|---|
| Default / new project / transformer | AdamW |
1e-3 (1e-4–5e-4 for transformers) | Robust, fast convergence |
| CNN from scratch (vision papers) | SGD + momentum 0.9 |
0.1 (with schedule) | Often best final accuracy, needs tuning |
| Quick prototype, small MLP | Adam/AdamW |
1e-3 | Least fiddling |
| Fine-tuning a pre-trained model | AdamW |
1e-5 – 5e-5 | Small LR to not destroy pre-trained weights |
If this book could only teach one hyperparameter lesson, it would be this: the learning rate matters more than the optimizer choice. Too large → loss explodes or oscillates (NaN losses, Chapter 10). Too small → training crawls and stalls in a mediocre spot. The standard practice:
Learning rate schedulers adjust LR during training — the near-universal pattern is to start higher and decay:
optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)
# ... in the epoch loop, after optimizer.step():
scheduler.step() # every 30 epochs, LR *= 0.1
Common schedulers: StepLR (drop by gamma every step_size epochs), CosineAnnealingLR (smooth cosine decay — popular in modern papers), ReduceLROnPlateau (drop LR when validation loss stalls). You don't need schedulers for this book's toy examples, but expect them in every serious training script you read.
To make the loss/task matching concrete, here's one dataset trained three ways — the only things changing are the head, targets, and loss:
import torch, torch.nn as nn
torch.manual_seed(0)
X = torch.randn(500, 4)
# Task A: regression target
y_reg = X.sum(dim=1, keepdim=True) + torch.randn(500, 1) * 0.1
# Task B: binary label from the same signal
y_bin = (y_reg > 0).float()
# Task C: 3-class label
y_cls = torch.bucketize(y_reg.squeeze(), torch.tensor([-1.0, 1.0])) # 0,1,2
def train(head_out, y, loss_fn, epochs=400):
m = nn.Sequential(nn.Linear(4, 16), nn.ReLU(), nn.Linear(16, head_out))
opt = torch.optim.AdamW(m.parameters(), lr=1e-2)
for _ in range(epochs):
opt.zero_grad(); loss = loss_fn(m(X), y); loss.backward(); opt.step()
return m, loss.item()
_, l_reg = train(1, y_reg, nn.MSELoss())
_, l_bin = train(1, y_bin, nn.BCEWithLogitsLoss())
_, l_cls = train(3, y_cls.long(), nn.CrossEntropyLoss())
print(f"regression MSE: {l_reg:.4f} | binary BCE: {l_bin:.4f} | 3-class CE: {l_cls:.4f}")
Same features, same network shape, three tasks — only the loss (and target format) changes. That is the lesson: the loss function is how you tell the network what problem it's solving.
For Your Research
Loss choice is a methods-section decision — justify it. Reviewers notice when a paper uses an unusual loss without explanation, and they also notice a well-motivated one. The default expectation: cross-entropy for classification, MSE for regression. If you deviate (focal loss for class imbalance, contrastive losses for representation learning, a custom composite loss), your paper needs one or two sentences saying why — e.g., "we use focal loss because positives are outnumbered 100:1." Keep a lab habit: every time you try a non-default loss in an experiment, write the one-sentence justification in your experiment log (Chapter 11). Half your future paper's methods section will assemble itself from those sentences.
Real datasets are rarely balanced — imagine 95% negative, 5% positive samples. Plain cross-entropy lets the model get 95% accuracy by always predicting negative, learning nothing. The standard fix is class weights: penalize mistakes on rare classes more:
# class_counts = [950, 50] -> weight rare class 19x more
weights = torch.tensor([1.0, 19.0])
loss_fn = nn.CrossEntropyLoss(weight=weights)
For binary tasks, BCEWithLogitsLoss(pos_weight=torch.tensor([19.0])) does the same. Rule of thumb: set weights inversely proportional to class frequencies, then tune. (More advanced: focal loss, which down-weights easy examples — worth knowing by name for your literature reviews on imbalanced problems.)
Sometimes the standard losses don't express your goal — e.g., you want MSE but with extra penalty on under-prediction. A loss is just a differentiable function returning a scalar; write it as one:
def asymmetric_mse(pred, target, under_penalty=2.0):
err = pred - target
# penalize under-prediction (err < 0) more heavily
weight = torch.where(err < 0, torch.full_like(err, under_penalty), torch.ones_like(err))
return (weight * err ** 2).mean()
# use it exactly like a built-in loss:
loss = asymmetric_mse(model(xb), yb)
loss.backward() # autograd handles the rest
Because it's built from tensor ops, autograd differentiates it automatically (Chapter 4's promise in action). Custom losses are a legitimate, common paper contribution — "we propose a loss that..." — and now you know they're just functions.
Theory is nice; watching optimizers behave differently builds intuition no table can. This trains the same tiny problem with three optimizers and prints the trajectory:
import torch, torch.nn as nn
torch.manual_seed(0)
X = torch.randn(200, 4); y = (X.sum(dim=1, keepdim=True) > 0).float()
def run(opt_name, lr):
m = nn.Sequential(nn.Linear(4, 16), nn.ReLU(), nn.Linear(16, 1))
opt = {"sgd": torch.optim.SGD(m.parameters(), lr=lr),
"sgd-m": torch.optim.SGD(m.parameters(), lr=lr, momentum=0.9),
"adamw": torch.optim.AdamW(m.parameters(), lr=lr)}[opt_name]
losses = []
for _ in range(100):
opt.zero_grad()
l = nn.BCEWithLogitsLoss()(m(X), y)
l.backward(); opt.step()
losses.append(l.item())
return losses[0], losses[9], losses[99]
for name, lr in [("sgd", 0.1), ("sgd-m", 0.1), ("adamw", 0.01)]:
l0, l10, l100 = run(name, lr)
print(f"{name:6s} loss: start {l0:.3f} -> ep10 {l10:.3f} -> ep100 {l100:.3f}")
Typical result: AdamW drops fastest early, SGD+momentum catches up, plain SGD lags — the standard story in one screen of output. Run it yourself with different LRs; the feel of "AdamW is forgiving, SGD needs its LR tuned" will stick.
Reduction controls how per-sample losses combine: reduction="mean" (default — one scalar for backward), "sum", or "none" (keep per-sample losses). "none" is useful when you need per-sample weighting or analysis:
loss_fn = nn.CrossEntropyLoss(reduction="none")
per_sample = loss_fn(model(xb), yb) # shape (B,) — inspect, weight, or mask
loss = per_sample.mean() # then reduce yourself
Researchers use this for importance weighting, hard-example mining, and loss landscapes per sample. If a paper says "we weight each sample's loss by...", this is the mechanism.
Label smoothing (a CrossEntropyLoss argument) softens hard 0/1 targets slightly (e.g., correct class gets 0.9 instead of 1.0), discouraging overconfident predictions:
loss_fn = nn.CrossEntropyLoss(label_smoothing=0.1)
It's a one-argument regularizer that helps in many classification settings — cheap to try in a sweep (Chapter 11) when a model overfits.
MSE's quadratic penalty assumes errors are roughly symmetric and outlier-free. Two common departures: (1) outliers — a few bad labels dominate the loss; switch to L1Loss or SmoothL1Loss. (2) Heteroscedastic noise — some inputs are inherently noisier than others; consider predicting uncertainty too (a model outputting mean and variance, trained with Gaussian negative log-likelihood — an intermediate topic you'll recognize when you meet it). The lesson generalizes: the loss encodes your assumptions about the noise — choose it as deliberately as the architecture.
BCEWithLogitsLoss and CrossEntropyLoss; targets are floats for BCE, int64 class indices for CE.Strip away every library, and training a neural network is one repeated procedure:

Every training script ever written — from your first MNIST classifier to a 70-billion-parameter language model — is this loop with better engineering around it. This chapter builds the full, honest version: device handling, mini-batches, train/validation split, metric tracking, and clean printing. Memorize its shape; you will write it hundreds of times.
We'll train a classifier on a synthetic 3-class problem (so it runs instantly on CPU), with a proper validation split. Read it slowly — every line earns its place:
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader, random_split
torch.manual_seed(0)
# ---------- 1. Data ----------
N = 3000
X = torch.randn(N, 8)
# 3 classes from a nonlinear rule + noise (so a linear model can't solve it)
logits_true = torch.stack([
X[:, 0] + X[:, 1],
X[:, 2] - X[:, 3] + 0.5 * X[:, 4] ** 2,
-X[:, 0] + X[:, 5],
], dim=1)
y = logits_true.argmax(dim=1) + (torch.rand(N) < 0.05).long() * 0 # clean labels
y = logits_true.argmax(dim=1)
full = TensorDataset(X, y)
train_ds, val_ds = random_split(full, [2400, 600],
generator=torch.Generator().manual_seed(0))
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=256)
# ---------- 2. Model, loss, optimizer ----------
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = nn.Sequential(
nn.Linear(8, 64), nn.ReLU(),
nn.Linear(64, 64), nn.ReLU(),
nn.Linear(64, 3),
).to(device)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-2, weight_decay=1e-4)
# ---------- 3. Training loop ----------
epochs = 25
for epoch in range(1, epochs + 1):
# --- train ---
model.train()
train_loss, correct, total = 0.0, 0, 0
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device)
optimizer.zero_grad() # 1. clear old gradients
out = model(xb) # 2. forward
loss = loss_fn(out, yb) # 3. loss
loss.backward() # 4. backward
optimizer.step() # 5. update weights
train_loss += loss.item() * xb.size(0)
correct += (out.argmax(dim=1) == yb).sum().item()
total += xb.size(0)
train_loss /= total
train_acc = correct / total
# --- validate ---
model.eval()
val_loss, correct, total = 0.0, 0, 0
with torch.no_grad(): # no graphs needed for evaluation
for xb, yb in val_loader:
xb, yb = xb.to(device), yb.to(device)
out = model(xb)
val_loss += loss_fn(out, yb).item() * xb.size(0)
correct += (out.argmax(dim=1) == yb).sum().item()
total += xb.size(0)
val_loss /= total
val_acc = correct / total
print(f"epoch {epoch:2d} | train loss {train_loss:.4f} acc {train_acc:.3f} "
f"| val loss {val_loss:.4f} acc {val_acc:.3f}")
Run it. You should see train loss falling from ~1.1 toward ~0.1, with validation accuracy climbing into the 0.95+ range. If your numbers differ slightly, that's fine — randomness (Chapter 11 covers taming it).
Data (section 1). TensorDataset wraps tensors so the loader can index them; random_split carves off validation data with a seeded generator (reproducible splits — note this, Chapter 11); DataLoader yields mini-batches, shuffling the training set each epoch. Why shuffle training but not validation? Shuffling training batches makes each gradient step see a different mix — the "stochastic" in SGD — which helps generalization. Validation just needs a stable, representative measurement.
Why mini-batches at all? Three reasons: (1) the full dataset rarely fits in GPU memory; (2) noisy per-batch gradients actually help escape sharp, poor minima; (3) more updates per epoch = faster learning. Batch size 32–256 is the normal range; it is itself a hyperparameter (larger batches train faster per epoch but may generalize slightly worse).
The train block. model.train() sets training mode (matters for dropout/BatchNorm, Chapter 5). The five-step rhythm from Chapter 4 is right there: zero_grad → forward → loss → backward → step. Metrics accumulate with loss.item() * batch_size — multiplying back by batch size because CrossEntropyLoss returns the mean over the batch, and the last batch may be smaller; weighting by actual batch size keeps the epoch average exact.
The validation block. model.eval() + torch.no_grad(): no dropout randomness, no gradient graphs — faster and less memory. We never call optimizer.step() here; validation data must not influence weights, or it stops being an honest measure. The validation accuracy is the number you actually care about — training accuracy tells you the model can learn; validation accuracy tells you it did learn something general.
epoch 1 | train loss 0.8234 acc 0.612 | val loss 0.6012 acc 0.745
epoch 5 | train loss 0.2145 acc 0.921 | val loss 0.1988 acc 0.933
epoch 15 | train loss 0.0512 acc 0.986 | val loss 0.0891 acc 0.972
epoch 25 | train loss 0.0188 acc 0.997 | val loss 0.0764 acc 0.978
Healthy training looks like this: both losses fall, both accuracies rise, validation tracks training with a small gap. Warning signs to recognize:
As scripts grow, factor the loop into functions. This is the shape real research code takes:
def train_one_epoch(model, loader, loss_fn, optimizer, device):
model.train()
total_loss, correct, total = 0.0, 0, 0
for xb, yb in loader:
xb, yb = xb.to(device), yb.to(device)
optimizer.zero_grad()
out = model(xb)
loss = loss_fn(out, yb)
loss.backward()
optimizer.step()
total_loss += loss.item() * xb.size(0)
correct += (out.argmax(dim=1) == yb).sum().item()
total += xb.size(0)
return total_loss / total, correct / total
@torch.no_grad()
def evaluate(model, loader, loss_fn, device):
model.eval()
total_loss, correct, total = 0.0, 0, 0
for xb, yb in loader:
xb, yb = xb.to(device), yb.to(device)
out = model(xb)
total_loss += loss_fn(out, yb).item() * xb.size(0)
correct += (out.argmax(dim=1) == yb).sum().item()
total += xb.size(0)
return total_loss / total, correct / total
# then the driver is five readable lines per epoch:
for epoch in range(1, epochs + 1):
tr_loss, tr_acc = train_one_epoch(model, train_loader, loss_fn, optimizer, device)
va_loss, va_acc = evaluate(model, val_loader, loss_fn, device)
print(f"epoch {epoch:2d} | train {tr_loss:.4f}/{tr_acc:.3f} | val {va_loss:.4f}/{va_acc:.3f}")
The @torch.no_grad() decorator on evaluate is equivalent to the with block — a tidy idiom you'll see in paper repositories.
For Your Research
Your training loop is the most-reviewed code you will write. When you submit code with a paper (or a reviewer asks for it), the training script is what they run first. Make it boring in the best way: fixed seeds, explicit device handling, separate train/eval functions, logged metrics every epoch, and saved checkpoints (Chapter 9). A reviewer who can run
python train.py --config configs/baseline.yamland reproduce your table's numbers is a reviewer who trusts your results. Every "boring" engineering choice in this chapter — seeded splits,.item()logging, eval mode — is a trust-building choice. Chapter 11 turns this loop into a full experiment harness.
Training longer isn't always better — past a point, the model memorizes training noise and validation worsens (overfitting). Early stopping watches validation loss and halts when it stops improving, keeping the best weights:
best_val = float("inf"); patience = 5; wait = 0
best_state = None
for epoch in range(1, 200):
tr_loss, tr_acc = train_one_epoch(model, train_loader, loss_fn, optimizer, device)
va_loss, va_acc = evaluate(model, val_loader, loss_fn, device)
if va_loss < best_val:
best_val, wait = va_loss, 0
best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}
else:
wait += 1
if wait >= patience:
print(f"early stop at epoch {epoch}, best val {best_val:.4f}")
break
model.load_state_dict(best_state) # restore the best weights, not the last ones
patience (typically 5–20) is how many stagnant epochs you tolerate — it forgives noisy validation curves. Note the .cpu().clone(): it snapshots weights off-GPU so later training steps don't mutate your saved best. Early stopping is the simplest regularization that actually works, and it belongs in every serious training script.
Occasionally gradients explode (very deep nets, RNNs, high LRs) — one bad batch sends weights to NaN and the run is dead. Gradient clipping caps the total gradient norm before the optimizer step:
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
One line, placed between backward() and step(). It doesn't change healthy training (norms below 1.0 are untouched) but prevents catastrophic steps. Cheap insurance — many researchers add it by default to new projects.
Printed numbers are fine; curves are better. Collect metrics during training and plot them — the shape of the curves diagnoses everything from Chapter 7's "how to read" section at a glance:
import matplotlib.pyplot as plt
history = {"train_loss": [], "val_loss": [], "val_acc": []}
for epoch in range(1, epochs + 1):
tr_loss, _ = train_one_epoch(model, train_loader, loss_fn, optimizer, device)
va_loss, va_acc = evaluate(model, val_loader, loss_fn, device)
history["train_loss"].append(tr_loss)
history["val_loss"].append(va_loss)
history["val_acc"].append(va_acc)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
ax1.plot(history["train_loss"], label="train"); ax1.plot(history["val_loss"], label="val")
ax1.set(title="Loss", xlabel="epoch"); ax1.legend()
ax2.plot(history["val_acc"], label="val acc", color="green")
ax2.set(title="Validation accuracy", xlabel="epoch"); ax2.legend()
plt.savefig("learning_curves.png"); plt.show()
Make this plot for every experiment and save the PNG next to metrics.csv (Chapter 11). Six months later, the curve will tell you more in five seconds than the CSV will in five minutes.
Accuracy hides which mistakes a model makes. A confusion matrix shows them — rows are true classes, columns are predictions:
@torch.no_grad()
def confusion_matrix(model, loader, device, num_classes):
model.eval()
cm = torch.zeros(num_classes, num_classes, dtype=torch.long)
for xb, yb in loader:
xb, yb = xb.to(device), yb.to(device)
pred = model(xb).argmax(dim=1)
for t, p in zip(yb.cpu(), pred.cpu()):
cm[t, p] += 1
return cm
cm = confusion_matrix(model, val_loader, device, 3)
print(cm)
# tensor([[190, 8, 2],
# [ 12, 175, 13],
# [ 3, 11, 186]])
Read it: row 1 (true class 1) shows 12 samples misclassified as class 0 and 13 as class 2 — if one pair of classes confuses the model systematically, that's a finding: maybe the classes genuinely overlap, maybe the features are insufficient, maybe the labels are noisy. Papers report confusion matrices precisely because they turn "94% accuracy" into an analysis of the remaining 6% — and the remaining errors are often where the next idea lives.
Before launching anything over an hour, run this five-minute gauntlet:
allclose.Each check is cheap; skipping them is how you discover at hour 30 that checkpoints were never written.
Instead of guessing the LR, measure it: train for a few hundred batches while increasing the LR exponentially each batch, and plot loss vs. LR. Loss falls steeply in a "good" LR window, then explodes — pick an LR near the steepest descent, typically ~10× below the explosion point:
lrs, losses = [], []
model = FreshModel().to(device)
opt = torch.optim.AdamW(model.parameters(), lr=1e-7) # start tiny
lr_mult = 1.02 # grow ~2% per batch
model.train()
for i, (xb, yb) in enumerate(train_loader):
if i > 300: break
xb, yb = xb.to(device), yb.to(device)
opt.zero_grad()
loss = loss_fn(model(xb), yb)
loss_val = loss.item()
if not losses:
init_loss = loss_val
if torch.isnan(loss) or loss_val > 4 * init_loss:
break # diverged — the window just ended
loss.backward(); opt.step()
lrs.append(opt.param_groups[0]["lr"]); losses.append(loss_val)
opt.param_groups[0]["lr"] *= lr_mult
import matplotlib.pyplot as plt
plt.semilogx(lrs, losses); plt.xlabel("lr (log)"); plt.ylabel("loss")
plt.savefig("lr_range_test.png")
This technique (from Leslie Smith's work on cyclical learning rates) turns the most important hyperparameter from a guess into a five-minute measurement. Run it once per new architecture/dataset pair; the plot goes next to your learning curves in the run directory.
loss.item() * batch_size).train_one_epoch / evaluate functions — that is the shape of real research code.Chapter 7 used TensorDataset — tensors wrapped for the loader. That works when your whole dataset fits in memory as tensors. Real research data rarely cooperates: images live as thousands of JPEG files on disk, text as raw documents, sensor readings as CSVs too large for RAM. You need a system that loads samples on demand, preprocesses them identically every time, and serves them in efficient batches — possibly using multiple background workers so the GPU never waits for data.
That system is Dataset + DataLoader, and understanding it deeply will save you from the most tedious class of research bugs: train/validation preprocessing mismatches, slow data pipelines, and memory blowups.
A Dataset is any class with two methods: __len__ (how many samples) and __getitem__(i) (return sample i). That's the entire contract — beautifully minimal:
import torch
from torch.utils.data import Dataset
class NumberDataset(Dataset):
"""Toy dataset: sample i is (features, label) with label = parity of i."""
def __init__(self, n):
self.n = n
def __len__(self):
return self.n
def __getitem__(self, i):
x = torch.tensor([float(i), float(i % 7)], dtype=torch.float32)
y = torch.tensor(i % 2, dtype=torch.long)
return x, y
ds = NumberDataset(1000)
print(len(ds)) # 1000
print(ds[42]) # (tensor([42., 0.]), tensor(0))
For real data, __getitem__ loads and preprocesses one sample — reads an image file, tokenizes a sentence, normalizes a signal. Keep __getitem__ deterministic given the index (no randomness unless it's intentional augmentation with a seeded generator): reproducibility (Chapter 11) depends on the data pipeline being stable.
The DataLoader wraps a dataset and yields batches:
from torch.utils.data import DataLoader
loader = DataLoader(ds, batch_size=32, shuffle=True, num_workers=0)
for xb, yb in loader:
print(xb.shape, yb.shape) # (32, 2) (32,) — then (32,2)... last batch may be smaller
break
Key arguments:
batch_size — samples per batch (32–256 typical).shuffle=True for training (reshuffles each epoch), False for validation/test.num_workers — background processes that run __getitem__ in parallel. 0 (default) loads in the main process — fine for small/toy data. For image datasets on disk, num_workers=4 (or 8) keeps the GPU fed; the rule of thumb is 2–4 workers per GPU. On Windows, workers require the script guard (if __name__ == "__main__":).pin_memory=True — speeds up transfer of batches to CUDA (use with GPU training).drop_last=True — drops the final smaller batch; useful when batch-size-sensitive ops (like BatchNorm) misbehave on tiny batches.How the batch is assembled: the loader calls __getitem__ batch_size times and stacks the results with the default collate_fn. It handles the standard cases (tensors → stacked; numbers → tensored). You only write a custom collate_fn when samples have variable sizes — e.g., sentences of different lengths that need padding to the batch's max length. That's an NLP rite of passage; for now, know it exists.
This is the pattern behind most vision research code — samples as files on disk, labels from directory names or a CSV:
import os, csv
import torch
from torch.utils.data import Dataset, DataLoader
class CSVDataset(Dataset):
"""Each row: feature_1,...,feature_8,label. Loaded lazily per sample."""
def __init__(self, csv_path):
with open(csv_path) as f:
rows = list(csv.reader(f))
self.header, self.rows = rows[0], rows[1:]
def __len__(self):
return len(self.rows)
def __getitem__(self, i):
*feats, label = self.rows[i]
x = torch.tensor([float(v) for v in feats], dtype=torch.float32)
y = torch.tensor(int(label), dtype=torch.long)
return x, y
# create a demo CSV so the example runs standalone
torch.manual_seed(1)
with open("/tmp/demo.csv", "w", newline="") as f:
w = csv.writer(f)
w.writerow([f"f{j}" for j in range(8)] + ["label"])
for _ in range(500):
feats = torch.randn(8)
label = int(feats[:2].sum() > 0)
w.writerow([f"{v:.4f}" for v in feats] + [label])
ds = CSVDataset("/tmp/demo.csv")
train_ds, val_ds = torch.utils.data.random_split(
ds, [400, 100], generator=torch.Generator().manual_seed(0))
train_loader = DataLoader(train_ds, batch_size=32, shuffle=True)
print(len(train_loader)) # 13 batches (12x32 + 1x16)
xb, yb = next(iter(train_loader))
print(xb.shape, yb.shape, yb.dtype) # (32, 8) (32,) torch.int64
Note the dtype discipline: features float32, labels long — exactly what CrossEntropyLoss expects (Chapter 6). Getting dtypes right in __getitem__ prevents a whole class of downstream errors.
For images, torchvision provides datasets (MNIST, CIFAR-10, ...) and transforms — composable preprocessing:
from torchvision import datasets, transforms
transform = transforms.Compose([
transforms.ToTensor(), # PIL image -> float tensor, scales to [0,1]
transforms.Normalize((0.5,), (0.5,)), # -> roughly [-1, 1]
])
train_ds = datasets.MNIST(root="./data", train=True, download=True, transform=transform)
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, num_workers=2)
xb, yb = next(iter(train_loader))
print(xb.shape) # (64, 1, 28, 28): (batch, channels, height, width)
The canonical image tensor layout is (N, C, H, W) — batch, channels, height, width. (Note: this is "channels-first"; TensorFlow/Keras defaults to channels-last. Mixing them up is a classic porting bug.) Normalize(mean, std) uses dataset statistics — for MNIST (0.1307,), (0.3081,) are the standard values; for ImageNet-pretrained models use ImageNet's ([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]).
Critical rule: identical preprocessing for train and validation, except augmentation. Data augmentation (random crops, flips) applies to training only; validation gets the deterministic pipeline. The standard structure:
train_tf = transforms.Compose([
transforms.RandomHorizontalFlip(), # augmentation: train only
transforms.RandomCrop(32, padding=4),
transforms.ToTensor(),
transforms.Normalize(mean, std),
])
val_tf = transforms.Compose([
transforms.ToTensor(), # deterministic: val/test
transforms.Normalize(mean, std),
])
A train/val preprocessing mismatch (e.g., normalizing one but not the other) produces the baffling symptom "great training accuracy, terrible validation accuracy" with no overfitting in sight — Chapter 10 lists it.
Data loading is the silent killer of training speed. If your GPU sits idle waiting for batches, wall-clock time explodes. The checklist:
num_workers=4 (tune: more isn't always better; watch CPU RAM).pin_memory=True when training on CUDA.__getitem__ every epoch.import time
t0 = time.time(); n = 0
for xb, yb in train_loader:
n += xb.size(0)
print(f"{n} samples in {time.time()-t0:.1f}s -> {n/(time.time()-t0):.0f} samples/s")
If samples/second is low and GPU utilization is low, your bottleneck is data loading, not the model — fix the pipeline before buying a bigger GPU.
For Your Research
Document your data pipeline like a method. Papers get rejected (or results questioned) over vague data handling: "we normalized the images" — how, with what statistics, in what order? Your
Datasetclass and transform pipeline are the precise answer, so keep them in version control and reference them: "preprocessing followed the pipeline indata/datasets.py(resize to 256, center-crop 224, normalize with ImageNet statistics)." Better still, log the transform list with each experiment (Chapter 11). When another lab emails "we couldn't reproduce your preprocessing," you send a file, not a paragraph from memory.
The default collate stacks same-shaped samples. For variable-length sequences (sentences, time series), you must pad each batch to its longest sequence. That's a custom collate_fn:
import torch
from torch.utils.data import Dataset, DataLoader
from torch.nn.utils.rnn import pad_sequence
class SeqDataset(Dataset):
def __init__(self, n=200):
torch.manual_seed(0)
self.seqs = [torch.randn(torch.randint(3, 10, (1,)).item(), 4) for _ in range(n)]
self.labels = torch.randint(0, 2, (n,))
def __len__(self): return len(self.seqs)
def __getitem__(self, i): return self.seqs[i], self.labels[i]
def pad_collate(batch):
seqs, labels = zip(*batch)
padded = pad_sequence(seqs, batch_first=True) # (B, T_max, 4), padded with 0
lengths = torch.tensor([len(s) for s in seqs]) # remember true lengths!
return padded, lengths, torch.stack(labels)
loader = DataLoader(SeqDataset(), batch_size=8, collate_fn=pad_collate)
xb, lens, yb = next(iter(loader))
print(xb.shape, lens, yb.shape) # e.g. (8, 9, 4) tensor([...]) (8,)
Two lessons: (1) pad_sequence does the mechanical padding; (2) always return the true lengths — the model needs them to ignore padding (via pack_padded_sequence for RNNs, or attention masks for transformers). Forgetting the lengths and letting the model "read" the zeros is a silent correctness bug: the model learns patterns in your padding.
torchvision.datasets ships far more than MNIST, all with the same interface:
from torchvision import datasets, transforms
tf = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])
cifar_train = datasets.CIFAR10(root="./data", train=True, download=True, transform=tf)
cifar_test = datasets.CIFAR10(root="./data", train=False, download=True, transform=tf)
print(len(cifar_train), cifar_train[0][0].shape, cifar_train.classes[:3])
# 50000 torch.Size([3, 32, 32]) ['plane', 'car', 'bird']
CIFAR-10 (60k tiny color images, 10 classes) is the canonical "my first real CNN" dataset — small enough for Colab, hard enough to be interesting. Also worth knowing: FashionMNIST (drop-in MNIST replacement, harder), ImageFolder (builds a dataset from a directory of class-named folders — the standard for custom image data), and Hugging Face datasets (load_dataset("...")) for the modern one-liner experience across thousands of datasets.
ImageFolder deserves special mention because it's how most custom vision projects start:
data/
train/
cats/ -> img001.jpg, img002.jpg, ...
dogs/ -> img101.jpg, ...
val/
cats/ ...
dogs/ ...
train_ds = datasets.ImageFolder("data/train", transform=train_tf)
print(train_ds.classes, train_ds.class_to_idx) # (['cats', 'dogs'], {'cats': 0, 'dogs': 1})
No CSV, no custom class — folder names become labels. For your first real image project, this is the path of least resistance.
Chapter 6 fixed imbalance with loss weights. The complementary fix is at the data level: sample rare classes more often with WeightedRandomSampler:
from torch.utils.data import WeightedRandomSampler
# class_counts: how many samples per class in the training set
class_counts = torch.tensor([950, 50])
class_weights = 1.0 / class_counts
sample_weights = class_weights[train_labels] # weight per sample by its class
sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True)
train_loader = DataLoader(train_ds, batch_size=32, sampler=sampler)
# NOTE: sampler and shuffle=True are mutually exclusive — the sampler defines the order
Each batch now sees roughly balanced classes even though the dataset isn't. Use loss weighting or balanced sampling (rarely both — that double-corrects). Which to choose? Sampling changes what the model sees; weighting changes what it cares about. Sampling is often more stable for extreme imbalance; weighting is simpler. Try both in a sweep — that's what sweeps are for.
Beyond random_split, Subset wraps any index list — useful for debugging on 100 samples or building custom folds:
from torch.utils.data import Subset
tiny = Subset(full_dataset, indices=list(range(100)))
tiny_loader = DataLoader(tiny, batch_size=32) # 4 batches — the debugging workhorse
The "debug on a tiny subset" habit (Chapter 10's minimal reproduction) starts here: if your pipeline works on 100 samples, data loading is innocent and the bug is elsewhere.
Two DataLoader flags for when you've outgrown the basics:
persistent_workers=True — keeps worker processes alive between epochs instead of respawning them. With num_workers=4, respawning every epoch can cost seconds per epoch; persistence removes that tax. (Only meaningful with num_workers > 0.)prefetch_factor=2 (default) — how many batches each worker prepares ahead. Raise it if the GPU still starves with workers running; lower it if RAM is tight.And a measurement habit: compare samples/s (Chapter 8's timing loop) before and after each change. Data-pipeline tuning without measurement is superstition — with measurement, it's engineering. One more: if your epoch time increases with more workers, you've hit CPU/RAM contention — back off. The optimum is empirical, usually 2–8 workers.
Chapter 7 used train/val. For publishable work you need three splits with strict roles:
from torch.utils.data import random_split
gen = torch.Generator().manual_seed(42)
train_ds, val_ds, test_ds = random_split(full_ds, [0.7, 0.15, 0.15], generator=gen)
Practical notes: use the same seeded split for every experiment you compare (or comparisons are meaningless); for small datasets prefer k-fold cross-validation over a single split; and never, ever let test samples leak into training transforms statistics (fit scalers/normalizers on train only).
Dataset needs only __len__ and __getitem__; DataLoader adds batching, shuffling, and parallel workers.__getitem__ deterministic per index; get dtypes right there (features float32, class labels long).num_workers=4 + pin_memory=True (on CUDA) keeps the GPU fed; profile samples/second before optimizing the model.Training runs take hours, days, or weeks. GPUs get preempted, Colab runtimes disconnect, laptops run out of battery. Every hour of training that isn't checkpointed is an hour you may have to redo — and in research, "redo" also means re-verifying that the rerun matches, which it won't exactly (Chapter 11). Worse: a published result whose model weights are lost can't be shared, inspected, or built upon.
PyTorch's saving system has one core idea: save the state_dict, not the model object. A state_dict is an ordinary Python dictionary mapping layer names to tensors — the model's learned numbers, nothing else. It's portable, inspectable, and version-proof in a way that pickled model objects are not.
import torch
import torch.nn as nn
model = nn.Sequential(nn.Linear(8, 64), nn.ReLU(), nn.Linear(64, 3))
# SAVE: the recommended way — state_dict only
torch.save(model.state_dict(), "model_weights.pt")
# LOAD: rebuild the architecture first, then load the numbers into it
model2 = nn.Sequential(nn.Linear(8, 64), nn.ReLU(), nn.Linear(64, 3))
model2.load_state_dict(torch.load("model_weights.pt", weights_only=True))
model2.eval()
# VERIFY: same weights -> same outputs
x = torch.randn(4, 8)
print(torch.allclose(model(x), model2(x))) # True
Three details that matter:
torch.save / torch.load use Python's pickle under the hood. The weights_only=True argument (default in recent versions) restricts loading to tensors — a security practice: never torch.load a file from an untrusted source without it, since pickle can execute arbitrary code.state_dict is just numbers; the nn.Module gives them meaning. This is why keeping model code in version control alongside weights is non-negotiable.allclose check takes one line and catches architecture mismatches, partial loads, and device mixups before they corrupt an experiment.What NOT to do: torch.save(model, "model.pt") — saving the entire model object. It pickles the class definition path, so loading breaks if you rename the file, move the class, or change PyTorch versions. Tutorials show it because it's short; research code avoids it because it's fragile. The one exception is quick personal checkpoints you will delete within the hour.
A state_dict saves the model. A checkpoint saves everything needed to resume training exactly: model weights, optimizer state (Adam's running averages — without them, resuming restarts the optimizer's memory from scratch), the epoch number, and the scheduler state:
def save_checkpoint(path, model, optimizer, scheduler, epoch, best_val, rng_state=None):
torch.save({
"epoch": epoch,
"model_state": model.state_dict(),
"optimizer_state": optimizer.state_dict(),
"scheduler_state": scheduler.state_dict() if scheduler else None,
"best_val": best_val,
}, path)
def load_checkpoint(path, model, optimizer, scheduler, device):
ckpt = torch.load(path, map_location=device, weights_only=False)
model.load_state_dict(ckpt["model_state"])
optimizer.load_state_dict(ckpt["optimizer_state"])
if scheduler and ckpt["scheduler_state"]:
scheduler.load_state_dict(ckpt["scheduler_state"])
return ckpt["epoch"], ckpt["best_val"]
(weights_only=False is needed here because the checkpoint contains non-tensor metadata like the epoch int. Only load checkpoints you created yourself.)
map_location solves the classic "trained on GPU, loading on CPU" crash: torch.load(path, map_location="cpu") remaps all tensors to CPU regardless of where they were saved. In Colab-to-laptop workflows, you will use this constantly.
In practice, maintain two files per experiment:
best_val = float("inf")
for epoch in range(1, epochs + 1):
tr_loss, tr_acc = train_one_epoch(model, train_loader, loss_fn, optimizer, device)
va_loss, va_acc = evaluate(model, val_loader, loss_fn, device)
# 1. rolling checkpoint: always the latest state (resume after crashes)
save_checkpoint("checkpoints/last.pt", model, optimizer, scheduler, epoch, best_val)
# 2. best-model snapshot: the weights you'd actually publish
if va_loss < best_val:
best_val = va_loss
torch.save(model.state_dict(), "checkpoints/best_model.pt")
print(f" -> new best val loss {best_val:.4f}, saved")
last.pt — overwritten every epoch; if the run dies at epoch 47, you resume from 47, not 0.best_model.pt — the weights behind your reported numbers. This is the file you share, analyze, and load for the paper's figures.Why track "best" by validation loss, not training loss? Training loss keeps falling as the model memorizes; validation loss tells you when generalization peaked. Reporting the final epoch's weights instead of the best epoch's is a quiet, common way to under-report your own results by a point or two.
start_epoch, best_val = 1, float("inf")
if os.path.exists("checkpoints/last.pt"):
start_epoch, best_val = load_checkpoint(
"checkpoints/last.pt", model, optimizer, scheduler, device)
start_epoch += 1 # continue with the NEXT epoch
print(f"resumed from epoch {start_epoch}")
for epoch in range(start_epoch, epochs + 1):
...
On Colab, where runtimes disconnect without warning, this pattern is the difference between a dead experiment and a paused one. Save last.pt to Google Drive (not the ephemeral runtime disk) if the run matters.
Because a state_dict is just a dict of tensors, you can inspect and modify it — useful for analysis (and for "For Your Research" below):
sd = model.state_dict()
for name, tensor in sd.items():
print(f"{name:20s} {str(tuple(tensor.shape)):12s} mean={tensor.mean():+.4f} std={tensor.std():.4f}")
# Example: freeze a layer by zeroing its gradients is done via requires_grad,
# but you can also directly inspect what the model learned:
w = sd["0.weight"] # first Linear layer's weight matrix
print("dead ReLU check — fraction of near-zero rows:", (w.abs().sum(dim=1) < 1e-6).float().mean().item())
For Your Research
Checkpoints are your paper's evidence locker. Reviewers increasingly ask for trained weights ("please release model checkpoints"), and venues like NeurIPS encourage it. Adopt this convention from your first serious experiment: every reported number in a table must trace to a specific
best_model.ptfile, and every such file lives next to a log of the exact config and code version that produced it (Chapter 11 ties these together). When you write "our model achieves 94.2%," you should be able to point at the file and the log line. This sounds bureaucratic; in practice it has saved countless researchers during rebuttal, when a reviewer asks for one more ablation and you need the exact weights, not an approximate rerun.
Chapter 5 showed freezing; here's the complete transfer-learning workflow — the single highest-value technique for a student with limited compute, since it converts weeks of someone else's GPU time into your starting point:
import torch.nn as nn
from torchvision.models import resnet18, ResNet18_Weights
# 1. Load pre-trained backbone
weights = ResNet18_Weights.DEFAULT
model = resnet18(weights=weights)
preprocess = weights.transforms() # the EXACT preprocessing it was trained with — use it!
# 2. Replace the head for your task (e.g., 10 classes)
model.fc = nn.Linear(model.fc.in_features, 10)
# 3. Two-phase training: head first, then everything
for p in model.parameters(): p.requires_grad = False
for p in model.fc.parameters(): p.requires_grad = True
opt = torch.optim.AdamW(model.fc.parameters(), lr=1e-3)
# ... train a few epochs (head adapts to new task) ...
for p in model.parameters(): p.requires_grad = True # unfreeze all
opt = torch.optim.AdamW(model.parameters(), lr=1e-5) # tiny LR: don't destroy features
# ... fine-tune a few more epochs ...
Three rules that make or break transfer learning: (1) use the pre-trained model's own preprocessing (weights.transforms()) — mismatched normalization silently cripples accuracy; (2) train the head first with the backbone frozen, so random head weights don't send destructive gradients into good features; (3) fine-tune with a tiny LR (1e-5-ish) — you're polishing, not rebuilding.
Sometimes architectures don't match exactly — you're loading old weights into a modified model (extra layer, different head). strict=False loads whatever matches and reports the rest:
# old checkpoint has layers: fc1, fc2. New model has fc1, fc2, fc3.
missing, unexpected = model.load_state_dict(torch.load("old.pt", weights_only=True),
strict=False)
print("missing:", missing) # ['fc3.weight', 'fc3.bias'] — randomly initialized, expected
print("unexpected:", unexpected) # [] — nothing in the file was unusable
This is also how you inspect what transferred: the missing/unexpected lists are a precise audit. Researchers use this for ablations like "initialize from checkpoint but re-randomize the top layers" — a clean way to ask which layers hold the useful knowledge.
When a model leaves the research script — a demo, a deployment — you want it runnable without your training code. TorchScript compiles a module into a serializable graph:
model.eval()
example = torch.randn(1, 3, 224, 224)
traced = torch.jit.trace(model, example) # record the graph for this input shape
traced.save("model_traced.pt")
# later, anywhere: loaded = torch.jit.load("model_traced.pt"); loaded(new_input)
Caveat: tracing records one execution path — data-dependent if statements get frozen to whichever branch ran. For models with dynamic control flow, torch.jit.script compiles the actual code instead. Full deployment (ONNX, mobile) is Chapter 12 territory; this is enough to know the door exists.
A weights file named model.pt in a folder of twelve model(1).pt files is a reproducibility failure waiting to happen. Adopt a naming convention that encodes provenance:
checkpoints/
20261008_baseline_lr1e-2_seed42_best.pt
20261008_baseline_lr1e-2_seed42_last.pt
Date, config nickname, key hyperparameters, seed, and role (best/last) — the filename alone answers "what is this?" And when you share weights (increasingly expected with papers), include a model card: a short README stating the architecture, training data, intended use, known limitations, and the exact config/commit that produced it. Hugging Face's hub made model cards standard practice; even a five-line card in your repo is enormously more useful than a bare .pt file.
A common worry: "will my checkpoint load in next year's PyTorch?" In practice, state_dicts are remarkably stable — they're just named tensors. What can break: (1) loading a full pickled model object (another reason to save state_dicts, Chapter 9's core rule); (2) custom classes referenced by old checkpoints; (3) very old checkpoints predating weights_only defaults. Mitigation is the same trio: save state_dicts, keep model code in version control, and note the PyTorch version in summary.json (Chapter 11). With those, decade-old checkpoints routinely load fine.
When someone just needs to use your model (not continue training), ship the smallest sufficient artifact:
# Export: weights + the metadata needed to use them
torch.save({
"state_dict": model.state_dict(),
"config": cfg, # architecture hyperparameters
"classes": train_ds.classes, # label meanings!
"preprocessing": "weights.transforms() or explicit params",
"torch_version": torch.__version__,
}, "released_model.pt")
The classes entry is the one everyone forgets: a checkpoint that outputs class index 2 is useless without knowing index 2 means "cat." Similarly, preprocessing parameters (normalization means/stds, image size) must travel with the weights — a model fed wrongly-normalized inputs fails silently (Chapter 10, Silent bug B). A self-describing artifact like this is what turns "here are my weights" into something another researcher can actually run.
For maximum compatibility, also consider exporting to ONNX (torch.onnx.export) — an open format runnable outside PyTorch entirely. It's the lingua franca for deployment, and reviewers in applied venues appreciate it.
Full checkpoints (model + optimizer + scheduler) for large models get big — Adam's state triples the weight size, so a 1 GB model means ~4 GB per checkpoint. Manage it:
import os, glob
def prune_checkpoints(ckpt_dir, keep_last_n=3):
"""Keep the N most recent last_*.pt; never touch best_model.pt."""
files = sorted(glob.glob(f"{ckpt_dir}/last_epoch*.pt"),
key=os.path.getmtime)
for f in files[:-keep_last_n]:
os.remove(f)
Conventions that scale: save rolling checkpoints as last_epoch{N}.pt and prune to the last 2–3; never auto-delete best_model.pt; for very long runs, also keep the checkpoint from the best few epochs, not just one (validation noise means "best" is uncertain). On Colab/Drive, watch your storage quota — a run that dies from a full disk at 3 a.m. is a rite of passage you only need once.
One more consideration: checkpoint frequency. Every epoch is right for short runs; for multi-day runs, save every N steps within epochs too (step-based rather than epoch-based checkpointing) so a crash never costs more than ~30 minutes of compute.
state_dict (the weights dict), not the model object — portable, inspectable, robust.last.pt (rolling, for crash recovery) and best_model.pt (best validation, for reporting/sharing).map_location when loading across devices; use weights_only=True for untrusted files.allclose — one line that catches silent corruption.Here is a secret senior researchers rarely advertise: a large fraction of "research" is debugging. Models don't converge, shapes don't match, GPUs run out of memory, and the error message points at line 200 when the bug is on line 40. The researchers who progress fastest aren't the ones who write bug-free code — they're the ones who recognize failure patterns instantly.
This chapter catalogs the ten errors you will hit, in roughly the order you'll meet them. For each: the symptom, why it happens, and the fix. Keep this chapter bookmarked.
Symptom:
RuntimeError: mat1 and mat2 shapes cannot be multiplied (32x10 and 20x64)
Why: A Linear layer (or @) received the wrong-sized input. Here a batch of shape (32, 10) hit a layer expecting 20 input features.
Fix: Print shapes at every stage. The error message tells you exactly what mismatched — read it literally: "32x10 multiplied by 20x64" means the layer's in_features is 20 but your data has 10. Either fix the data pipeline or the layer definition. Defensive habit: after building a model, run one batch through it immediately and print shapes:
xb, _ = next(iter(train_loader))
print("input:", xb.shape)
out = model(xb)
print("output:", out.shape) # does this match what the loss expects?
Symptom:
RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu!
Why: The model is on GPU but the batch is on CPU (or vice versa). The single most common crash in GPU training.
Fix: The Chapter 2/7 pattern — one device variable, .to(device) for model and every batch:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
# inside the loop:
xb, yb = xb.to(device), yb.to(device)
If the error persists, some tensor was created inside the model or loss without going through .to(device) — e.g., a mask or target created with torch.zeros(...) defaults to CPU. Create it on the right device: torch.zeros(..., device=xb.device).
Symptom: No crash — worse. Loss decreases oddly, oscillates, or explodes; results don't match any tutorial.
Why: Gradients accumulate across batches (Chapter 4). Without zeroing, each step uses the sum of all past gradients — training on a phantom of old data.
Fix: Make the five-step rhythm non-negotiable: zero_grad → forward → loss → backward → step, in that order, every iteration. If loss behaves strangely and there's no error message, check for the missing zero_grad() first — it's the prime suspect.
Symptom:
RuntimeError: grad can be implicitly created only for scalar outputs
Why: You called .backward() on a vector/matrix — e.g., loss = loss_fn(out, yb) where you forgot the loss reduces, or you called backward on raw outputs.
Fix: Ensure the thing you call .backward() on is a scalar: loss.backward() where loss = loss_fn(...) with a default reduction ('mean'). If you genuinely need vector gradients, pass gradient=torch.ones_like(output).
Symptom: No crash. Training is mysteriously slow or stuck; accuracy far below expectations.
Why: CrossEntropyLoss and BCEWithLogitsLoss apply softmax/sigmoid internally in a numerically stable form. Applying it yourself first squashes the signal twice — gradients nearly vanish.
Fix:
# WRONG:
loss = nn.CrossEntropyLoss()(torch.softmax(model(xb), dim=1), yb)
# RIGHT:
loss = nn.CrossEntropyLoss()(model(xb), yb) # raw logits in
Apply softmax/sigmoid only when you need probabilities out (evaluation, inference) — never before these losses.
Symptom:
RuntimeError: Expected target size (32,), got torch.Size([32, 1])
# or
RuntimeError: expected scalar type Long but found Float
Why: CrossEntropyLoss wants class indices as int64 shape (N,); BCEWithLogitsLoss/MSELoss want floats matching the output shape.
Fix: The Chapter 6 table, enforced at the data source:
# classification: squeeze to (N,) and cast
yb = yb.long().squeeze()
# binary/regression: match output shape and float
yb = yb.float()
Better: get dtypes right in Dataset.__getitem__ (Chapter 8) so the fix is in one place.
Symptom:
RuntimeError: CUDA out of memory. Tried to allocate 1.2 GiB...
Why: The model + a batch + gradients + optimizer state exceed GPU RAM. Note the optimizer state: Adam stores two extra numbers per parameter — it roughly triples memory vs. the weights alone.
Fixes, in order: 1. Smaller batch size (halve it; e.g., 128 → 64). 2. Smaller model or shorter sequences. 3. Gradient accumulation: simulate a big batch with several small ones —
accum_steps = 4
optimizer.zero_grad()
for i, (xb, yb) in enumerate(train_loader):
loss = loss_fn(model(xb.to(device)), yb.to(device)) / accum_steps
loss.backward() # accumulates
if (i + 1) % accum_steps == 0:
optimizer.step() # one update per 4 batches
optimizer.zero_grad()
torch.no_grad(), del large temporaries, torch.cuda.empty_cache() (rarely the real fix, but harmless).torch.cuda.amp) or a bigger GPU (Chapter 12).Symptom:
RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation
Why: You modified a tensor in-place (e.g., x += 1, relu_(), or x[mask] = 0) after it was recorded in the graph; autograd needs the original value for the backward pass.
Fix: Use out-of-place ops: x = x + 1 instead of x += 1; nn.ReLU() instead of nn.ReLU(inplace=True) while debugging (in-place ReLU is a memory optimization — fine once the code works, risky while developing). The same applies to weight updates: always inside torch.no_grad() (Chapter 4).
Symptom: No crash. Test accuracy is noisy, lower than expected, or varies run to run; sometimes it's higher than train accuracy in confusing ways.
Why: Dropout still dropping and BatchNorm using batch statistics during evaluation — the model is being measured with training-time randomness.
Fix: The evaluation ritual, every time:
model.eval()
with torch.no_grad():
...evaluate...
model.train() # switch back if training continues
Symptoms: RuntimeError: DataLoader worker exited unexpectedly / training absurdly slow / TypeError inside the loop.
Why & fixes:
- Worker crashes (num_workers > 0): often shared-memory limits in Docker/Colab or unpicklable objects in Dataset. First debug step: set num_workers=0 — if it works, the bug is in multiprocessing, not your data.
- Slow training, idle GPU: num_workers too low or heavy per-sample preprocessing (Chapter 8's checklist).
- default_collate errors: samples returning inconsistent types/shapes (e.g., None for a corrupt file). Make __getitem__ total: handle or skip bad samples explicitly, never return None.
When none of the ten match, run this sequence — it resolves the vast majority of remaining issues:
print(x.shape, x.dtype, x.device) at each stage. The bug is wherever reality first diverges from expectation.zero_grad → forward → loss → backward → step — in order, every iteration.git diff — the bug is in the diff. (Another reason Chapter 11's version-control habit matters.)For Your Research
The one-batch overfit test belongs in your lab's onboarding. Every new model, every new dataset, every ported paper implementation: before any hyperparameter tuning, before any multi-hour run, overfit a single batch. It takes two minutes and answers the only question that matters first — "is this code capable of learning at all?" Researchers who skip it spend days tuning learning rates on broken code; researchers who run it debug in minutes. When you mentor junior students, teach this test alongside the framework. It's the closest thing deep learning has to a unit test for "my training works."
The ten errors above at least crash. The truly expensive bugs run fine and give you wrong conclusions. Watch for these four:
Silent bug A: Data leakage. Validation/test data influences training — e.g., computing normalization statistics over the full dataset (including test), or augmenting before the split. Symptom: suspiciously good validation numbers that collapse on truly new data. Fix: split first, then fit every preprocessing statistic on the training split only.
Silent bug B: Train/val preprocessing mismatch. Training normalizes with one mean/std, validation with another (or none). Symptom: good training accuracy, terrible validation, no overfitting pattern. Fix: define transforms once (Chapter 8's train_tf/val_tf sharing the same Normalize), and diff them when this symptom appears.
Silent bug C: Unseeded comparisons. You compare "model A vs model B" but each ran with different random initialization, shuffling, and splits. Symptom: conclusions that flip when rerun. Fix: same seed and same data split for compared runs (Chapter 11); better, run each config over 3 seeds and report mean ± std — the standard reviewers expect.
Silent bug D: The metric lies. Accuracy on a 99-to-1 imbalanced dataset is 99% for a model that predicts "no" always. Symptom: a number that looks great and means nothing. Fix: match the metric to the task — precision/recall/F1 or balanced accuracy for imbalance, MSE/MAE for regression — and always look at a confusion matrix before celebrating.
A fifth, worth naming: tuning on the test set. Every time you adjust hyperparameters based on test numbers, the test set becomes validation data and your reported numbers become optimistic. Keep a held-out test set you touch once, at the very end. This is methodology, not PyTorch — but PyTorch makes it so easy to re-run that the temptation is constant.
PyTorch tracebacks look long but have a simple structure: read bottom-up. The last lines name the error and the failing operation; scrolling up finds the first frame in your code (not library internals) — that's where your bug lives. Example:
RuntimeError: mat1 and mat2 shapes cannot be multiplied (32x10 and 20x64)
...
File "train.py", line 42, in train_one_epoch
out = model(xb)
Line 42, your code, shape mismatch — diagnosed in seconds. Train yourself to skip library frames on sight; the answer is always in your frames.
NaN loss is the most dreaded symptom because it tells you nothing about where it started. The systematic hunt:
if torch.isnan(loss):
print(f"NaN at epoch {epoch}, batch {i}")
break
torch.isnan(xb).any() — corrupt inputs happen), in the model output, or only after the loss? Each answer halves the search space.log(0) or division by a zero-ish value in a custom loss (add small epsilon: torch.log(x + 1e-8)); mixed-precision overflow (Chapter 12); exploding gradients in deep/recurrent nets (gradient clipping, Chapter 7).The worst bugs are intermittent — a crash every ~20 runs. Strategy: make it deterministic first, then debug. Set all seeds (Chapter 11), num_workers=0, cudnn.deterministic=True, and rerun until it reproduces reliably. A bug that reproduces every time is a bug you can bisect; a bug that reproduces "sometimes" is a bug you'll chase for weeks. Only after fixing it do you relax determinism for speed. This "deterministic cage" technique is standard practice in serious debugging — randomness is the enemy of diagnosis.
zero_grad() → double-squashed loss → eval mode.torch.load untrusted files without weights_only=True.num_workers=0), then scale back up.So far this book's code has lived in single scripts and notebooks. That is fine for learning — and notebooks are genuinely good for exploration. But research has a stricter standard: an experiment is a claim about what happens under specific conditions, and it only counts if someone else (or you, in six months) can recreate those conditions exactly and get the same result. A notebook with cells run in unknown order, an unrecorded learning rate, and weights saved as model_final_FINAL2.pt does not meet that standard.
This chapter turns a training script into an experiment harness: seeded randomness, configuration files, structured logging, and a directory layout that makes every run reproducible and every result traceable. None of this is glamorous. All of it is what separates "I tried something" from "I ran an experiment."
Deep learning is full of randomness: weight initialization, data shuffling, augmentation, dropout. Two runs with different seeds give slightly different numbers — normal and expected. But unrecorded randomness is a reproducibility killer. The fix is a seed function called once at startup:
import random
import numpy as np
import torch
def set_seed(seed: int):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
# Deterministic algorithms where possible (may cost some speed):
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
set_seed(42)
Honest caveats a researcher should know:
- Seeding makes runs reproducible on the same machine, same software versions, same GPU. Across different GPUs or PyTorch versions, some operations are nondeterministic at the hardware level — results may differ in the last decimal places.
- cudnn.deterministic = True can slow training; many labs seed everything except this for long runs, and enable it only when exact reproducibility is needed (e.g., the final reported run).
- Always log the seed with the results. "Seed 42" is part of the method.
Also seed your data splits (Chapter 7's random_split with a seeded generator) — otherwise your "train/val split" differs every run and comparisons between experiments are meaningless.
Every number that isn't architecture — learning rate, batch size, epochs, seed, data paths — belongs in a config, not hard-coded. The simplest honest config is a YAML file (or a dataclass); the point is that the code never changes between experiments, only the config does:
# config.yaml
seed: 42
data:
path: ./data
batch_size: 64
model:
hidden_dim: 64
num_layers: 2
dropout: 0.1
train:
epochs: 25
lr: 0.01
weight_decay: 1e-4
optimizer: adamw
# train.py (sketch)
import yaml, argparse
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
args = parser.parse_args()
with open(args.config) as f:
cfg = yaml.safe_load(f)
set_seed(cfg["seed"])
model = build_model(cfg["model"]) # hyperparams from config, not literals
optimizer = torch.optim.AdamW(model.parameters(), lr=cfg["train"]["lr"],
weight_decay=cfg["train"]["weight_decay"])
Now an experiment is fully described by python train.py --config configs/exp07_lr001.yaml — a command you can paste into a paper's reproducibility note, a lab wiki, or a message to a collaborator. Keep every config you ever run in configs/ under version control: the history of configs is the history of your experimental decisions.
Print statements vanish. A proper experiment writes its results to files: per-epoch metrics (for curves), final metrics (for tables), and the config (for provenance). The minimal version is a CSV plus a JSON summary:
import csv, json, os, time
run_dir = f"runs/{time.strftime('%Y%m%d_%H%M%S')}_lr{cfg['train']['lr']}"
os.makedirs(run_dir, exist_ok=True)
# 1. save the config with the run
with open(f"{run_dir}/config.json", "w") as f:
json.dump(cfg, f, indent=2)
# 2. per-epoch metrics
with open(f"{run_dir}/metrics.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["epoch", "train_loss", "train_acc", "val_loss", "val_acc"])
for epoch in range(1, epochs + 1):
tr_loss, tr_acc = train_one_epoch(...)
va_loss, va_acc = evaluate(...)
writer.writerow([epoch, f"{tr_loss:.4f}", f"{tr_acc:.4f}",
f"{va_loss:.4f}", f"{va_acc:.4f}"])
# 3. final summary (what goes into the paper's table)
summary = {"best_val_loss": best_val, "best_val_acc": best_acc,
"best_epoch": best_epoch, "seed": cfg["seed"],
"torch_version": torch.__version__}
with open(f"{run_dir}/summary.json", "w") as f:
json.dump(summary, f, indent=2)
This is deliberately low-tech: no special tools, readable forever, diffable in git. When you're ready for more, the standard upgrades are TensorBoard (torch.utils.tensorboard) for live curves and Weights & Biases for cloud-tracked sweeps — both read the same per-epoch numbers; the discipline of logging them is the part that matters.
One more logging rule: log what you will put in the table, at the time you compute it. Reconstructing "what was the best validation accuracy of the March run?" from memory is how errors enter papers. The summary.json habit makes the paper's results table a copy-paste from files.
A layout that scales from first project to thesis:
my-project/
configs/ # one YAML per experiment — version controlled
data/ # raw data (or scripts to fetch it); never edit by hand
src/
model.py # nn.Module definitions
data.py # Dataset classes, transforms, loaders
train.py # training loop + checkpointing (--config entry point)
evaluate.py # loads best_model.pt, reports test metrics
runs/ # one timestamped dir per run: config.json, metrics.csv,
# summary.json, checkpoints/ (git-ignored; big files)
notebooks/ # exploration only — nothing here counts as a result
requirements.txt # or environment.yml — the recorded environment
Rules of thumb: src/ is imported, never copy-pasted; runs/ is written by code, never by hand; notebooks/ are for thinking, src/ is for claiming. When a collaborator (or reviewer) asks "how did you get Table 2?", the answer is: the config in configs/, the code in src/, the outputs in runs/<timestamp>/.
Use git from day one — not as ceremony, but because the diff is the record of what changed between experiments:
git init
git add src configs requirements.txt
git commit -m "baseline MLP, lr=0.01"
# ... run experiment, get results ...
git tag exp07-baseline # mark the exact code behind a reported number
The .gitignore should exclude runs/ (large, regenerable) and data/ (large, fetched) — but never exclude configs/. When Chapter 10's debugging protocol says "the bug is in the diff," this is what it means. And when a reviewer asks for an ablation six months later, git checkout exp07-baseline puts you back in the exact room where the result was born.
Before launching any run that might end up in a paper, verify:
set_seed, seed in config and summary)torch.__version__, GPU, requirements.txt)metrics.csv; final numbers to summary.jsonlast.pt rolling + best_model.pt by validation (Chapter 9)This checklist takes five minutes and prevents the two classic disasters: "I can't reproduce my own best result" and "I don't know which run produced Table 3."
For Your Research
Reproducibility is a competitive advantage, not overhead. The ML community is moving steadily toward requiring it — reproducibility checklists at major conferences, artifact evaluation, "papers with code" as the norm. Students who build these habits early produce work that gets cited and built upon, because other researchers can actually use it. More selfishly: the best person these habits serve is you, three months from now, trying to extend your own paper for the journal version. Future-you will thank present-you for every config file, every seed, every
summary.json. Treat experiment organization as part of the research contribution, not as janitorial work before the "real" research starts.
CSV logging is forever-readable; TensorBoard adds live curves you can watch during training. It's built into PyTorch — no new concepts, just a writer object:
from torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter(f"runs/tb_lr{cfg['train']['lr']}") # one dir per run
for epoch in range(1, epochs + 1):
tr_loss, tr_acc = train_one_epoch(...)
va_loss, va_acc = evaluate(...)
writer.add_scalar("loss/train", tr_loss, epoch)
writer.add_scalar("loss/val", va_loss, epoch)
writer.add_scalar("acc/val", va_acc, epoch)
writer.add_scalar("lr", optimizer.param_groups[0]["lr"], epoch)
writer.close()
# then: tensorboard --logdir runs (open the URL it prints)
Log the learning rate too — when a run behaves oddly, the first question is "what was the LR doing?", and schedulers make it non-obvious. TensorBoard also visualizes model graphs and histograms of weights/gradients (add_histogram), which occasionally reveals dead layers at a glance. Keep the CSV habit alongside it: TensorBoard is for watching, files are for the record.
Sooner or later you'll grid-search hyperparameters. The disciplined way is a loop over configs, each producing its own run directory — never hand-edited runs:
import itertools, copy
grid = {"lr": [1e-2, 1e-3], "hidden_dim": [32, 64], "seed": [0, 1]}
keys = list(grid)
for values in itertools.product(*(grid[k] for k in keys)):
cfg_run = copy.deepcopy(base_cfg)
for k, v in zip(keys, values): cfg_run["train"][k] = v # (adjust nesting as needed)
run_dir = f"runs/sweep_{'_'.join(f'{k}{v}' for k, v in zip(keys, values))}"
train_with_config(cfg_run, run_dir) # your Chapter 7 loop + Chapter 9 checkpoints
Two sweep rules from hard experience: (1) vary one thing at a time when diagnosing, grid when exploring — a full grid over 4 hyperparameters is 81 runs and teaches you less than you think; (2) include seeds in the sweep for any comparison that matters (Silent bug C, Chapter 10). After the sweep, the summary table practically writes itself:
| lr | hidden_dim | seed | best val acc |
|---|---|---|---|
| 1e-2 | 32 | 0 | 0.961 |
| 1e-2 | 32 | 1 | 0.958 |
| ... | ... | ... | ... |
That table — with seeds, from files, reproducible — is what "we performed a hyperparameter sweep" means in a paper. Anything less is hand-waving.
Record the environment alongside the code. This is enough to start:
torch>=2.0
torchvision>=0.15
numpy
matplotlib
pyyaml
tensorboard
Generate the exact pinned versions with pip freeze > requirements.txt at publication time. Pinning matters: "torch>=2.0" is a wish; "torch==2.3.1" is a record.
Every research project should have a README.md that lets a stranger reproduce your main result. Not your lab mate — a stranger, with a fresh machine. The template:
# Project title — one-line description
## Results
| Experiment | Val accuracy | Config |
|---|---|---|
| baseline | 0.962 | configs/baseline.yaml |
## Reproduce
1. pip install -r requirements.txt
2. python src/download_data.py # or: place data in data/
3. python src/train.py --config configs/baseline.yaml
4. python src/evaluate.py --checkpoint runs/<timestamp>/checkpoints/best_model.pt --config configs/baseline.yaml
Expected: ~0.96 val accuracy in ~20 min on a T4 GPU (Colab).
Write it while the project is fresh, and test it by following it yourself on a clean environment (or asking a colleague to). READMEs written from memory months later are fiction; READMEs tested by execution are documentation. Reviewers who can reproduce your work in four commands trust your work — and cite it.
Before any run whose numbers might be published, review the training script against this list (or better, have a colleague do it — fresh eyes catch what yours skip):
random, numpy, torch, CUDA, and data splits — and loggedmodel.train()/model.eval() + torch.no_grad() in the right placeslast + best) and the round-trip was testedNine boxes, five minutes, and the large majority of "embarrassing rebuttal" situations never happen.
random, numpy, torch, CUDA, data splits) and log the seed.src/ + configs/ + runs/ layout; version-control code and configs with git, tag reported results.Take stock: you can create and manipulate tensors, differentiate through arbitrary computations, build networks with nn.Module, choose losses and optimizers, write full training loops with validation, feed real data through custom datasets, checkpoint experiments, debug the ten classic failures, and organize runs reproducibly. That is the complete foundation — everything else in the PyTorch world is a power tool built on top of it, and power tools make sense only once the foundation is solid.
This final chapter maps where to go next, in the order that pays off for a researcher. For each direction: what it is, when you need it, and the honest cost of adopting it.
What: A lightweight framework that organizes your PyTorch code into a LightningModule (model + training step + validation step) and a Trainer (the loop, checkpointing, logging, multi-GPU). Your Chapter 7 loop becomes:
# sketch — the ideas, not a full tutorial
class LitClassifier(pl.LightningModule):
def training_step(self, batch, batch_idx):
x, y = batch
loss = self.loss_fn(self(x), y)
self.log("train_loss", loss)
return loss
def configure_optimizers(self):
return torch.optim.AdamW(self.parameters(), lr=1e-3)
trainer = pl.Trainer(max_epochs=25, accelerator="gpu", devices=1)
trainer.fit(model, train_loader, val_loader)
When: As soon as your training scripts get repetitive — usually your second or third project. Cost: You must learn its conventions (hooks like training_step); debugging goes through an extra layer. Verdict: worth it for multi-experiment research; skip it while you're still internalizing the raw loop.
What: The largest public library of pre-trained models — BERT, GPT-style models, Vision Transformers, Stable Diffusion — with a unified API, plus datasets for one-line dataset loading.
from transformers import AutoModelForSequenceClassification, AutoTokenizer
tok = AutoTokenizer.from_pretrained("bert-base-uncased")
model = AutoModelForSequenceClassification.from_pretrained("bert-base-uncased", num_labels=3)
When: The moment your research touches language, vision-language, or generative models — which, statistically, it will. Fine-tuning a pre-trained model beats training from scratch in most student-scale projects. Cost: These are big models with big APIs; start with a fine-tuning tutorial on a small dataset before adapting to your problem. This is also where the tiny learning rates from Chapter 6 (1e-5) become daily practice.
What: PyTorch 2's built-in compiler: one line that fuses and optimizes your model's operations, often 20–50% faster with no code changes:
model = torch.compile(model) # that's the whole API
When: When training is too slow and you've already fixed data loading (Chapter 8). Cost: First-epoch compilation takes time; rare incompatibilities with dynamic control flow. Try it early — it's nearly free performance.
What: Training across several GPUs/machines with DistributedDataParallel (DDP): each GPU processes different batches, gradients are averaged. Lightning and Hugging Face's accelerate wrap the considerable setup boilerplate.
When: When one GPU can't finish your experiment in reasonable time — typically large transformers or ImageNet-scale vision. Cost: Real engineering complexity (process groups, samplers, synchronization bugs). Don't touch this until single-GPU training is second nature; most MS theses never need it.
torch.cuda.amp): trains with 16-bit floats where safe — roughly 2x speed and half the memory on modern GPUs, with minimal accuracy impact. The standard for large-model training.When: Mixed precision when memory/speed binds at scale; quantization and deployment when your research needs to ship, not just publish. For most early researchers these are "know they exist" topics until a project demands them.
From Chapter 1: learn JAX when you want maximum mathematical control and speed (its grad/jit/vmap transformations are genuinely beautiful for methods research), and TensorFlow/Keras when a job or deployment target requires it. Learning either is dramatically easier after PyTorch, because the concepts — tensors, graphs, losses, optimizers — transfer completely; only the API differs.
Concretely, here's the smallest useful action for each item above — do these in order, and never all at once:
train_one_epoch/evaluate script and convert it to a LightningModule following the official "Lightning in 15 minutes" tutorial. If the conversion feels like it's hiding things you want to see, you're not ready — come back after two more raw-PyTorch projects.Trainer, tiny LR — is the same everywhere in this ecosystem.torch.compile: Add the one line to your fastest existing script and measure wall-clock time before/after. If you hit a compilation error, wrap the failing region with torch._dynamo.disable or fall back — don't fight the compiler on your first try.accelerate (it handles the boilerplate) on 2 GPUs, and read the DDP docs only when you need to understand a failure.torch.cuda.amp following the official AMP recipe on an existing working script. Verify accuracy matches the full-precision run before trusting it — silent degradation is the failure mode.The meta-rule: one new tool per project. Researchers who adopt three new tools at once can't tell which one broke; researchers who add one at a time build a reliable toolkit.
| Weeks | Focus | Milestone |
|---|---|---|
| 1–2 | Chapters 2–7 of this book | Train an MLP/CNN on MNIST or CIFAR-10 from scratch; hit the expected accuracy |
| 3–4 | Chapters 8–11 | Reorganize into src/ + configs + runs; reproduce a tiny published result (e.g., a paper's MNIST baseline) and log everything |
| 5–6 | torchvision pre-trained models | Fine-tune a pre-trained ResNet on a small dataset of your choice |
| 7–8 | Hugging Face | Fine-tune a small transformer (e.g., DistilBERT) on a text classification task |
| 9–12 | Your research problem | Apply the full pipeline — data, model, experiment harness — to your thesis/curiosity problem; write it up |
The pattern to internalize: foundation → pre-trained models → your problem. Each stage reuses everything before it.
train.py and model.py. You'll start recognizing this book's patterns everywhere — that's the point.For Your Research
Your next paper starts with a reproduced baseline. The single most valuable habit for an early researcher: before proposing anything new, reproduce the strongest existing baseline on your problem, with this book's full harness (seeded, logged, checkpointed). A reproduced baseline gives you three things: (1) certainty your pipeline works, (2) a fair comparison point reviewers trust, and (3) deep familiarity with the problem that often suggests the novel idea itself. Most "novel" contributions are born while staring at a baseline's failure cases. The researcher who can train, debug, and reproduce is the researcher whose new ideas actually get tested — and tested ideas become papers.
torch.compile is nearly free speed.| Task | API | Notes |
|---|---|---|
| Create tensor | torch.tensor, torch.randn, torch.zeros/ones, torch.arange |
randn = standard normal |
| Inspect | x.shape, x.dtype, x.device, x.numel() |
Print these when debugging |
| Reshape | x.reshape(...), x.view(...), x.unsqueeze/squeeze, x.T |
cat glues existing dim; stack adds new dim |
| Math | + - * / @, torch.matmul, .sum/.mean(dim=), .argmax(dim=) |
@ = matmul, * = element-wise |
| Device | x.to(device), x.cpu(), x.cuda() |
Model and data must match |
| NumPy bridge | torch.from_numpy(a), x.numpy() |
Shared memory; CPU only |
| Track gradients | requires_grad=True, x.backward(), x.grad |
Backward needs scalar |
| Pause tracking | torch.no_grad(), x.detach() |
Eval, logging, manual updates |
| Build model | nn.Module, nn.Linear, nn.Sequential, nn.Parameter |
Call model(x), not forward |
| Activations | nn.ReLU, nn.Sigmoid, nn.Tanh, nn.Softmax(dim=1) |
ReLU default for hidden |
| Losses | nn.MSELoss, nn.BCEWithLogitsLoss, nn.CrossEntropyLoss |
Logits in, no softmax first |
| Optimizers | torch.optim.SGD, Adam, AdamW |
zero_grad → backward → step |
| Schedulers | StepLR, CosineAnnealingLR, ReduceLROnPlateau |
Decay LR during training |
| Data | Dataset, DataLoader, TensorDataset, random_split |
Shuffle train, seed splits |
| Save/load | torch.save/load, model.state_dict(), load_state_dict |
Save dicts, not objects |
| Reproducibility | torch.manual_seed, random.seed, np.random.seed |
Log the seed |
| Data | Shape | Example |
|---|---|---|
| Scalar (loss) | () |
loss.item() → Python float |
| One sample, F features | (F,) |
(8,) |
| Batch of samples | (B, F) |
(64, 8) |
| Batch of images | (B, C, H, W) |
(64, 3, 32, 32) |
| Batch of sequences | (B, T, F) |
(32, 50, 128) |
| Linear layer weights | (out, in) |
nn.Linear(10, 32) → (32, 10) |
| Class logits | (B, K) |
(64, 3) for 3 classes |
| Class targets | (B,) int64 |
values 0..K-1 |
| Task | Loss | Output | Targets |
|---|---|---|---|
| Regression | MSELoss |
(N,1) floats |
(N,1) floats |
| Binary classification | BCEWithLogitsLoss |
(N,1) logits |
(N,1) float 0/1 |
| Multi-class (K) | CrossEntropyLoss |
(N,K) logits |
(N,) int64 indices |
| Multi-label | BCEWithLogitsLoss |
(N,K) logits |
(N,K) float 0/1 |
| Situation | Optimizer | LR starting point |
|---|---|---|
| Default / prototype / transformer | AdamW |
1e-3 (1e-4 for transformers) |
| CNN from scratch | SGD + momentum 0.9 |
0.1 with scheduler |
| Fine-tuning pre-trained | AdamW |
1e-5 – 5e-5 |
| Hyperparameter | Start | If loss explodes/NaN | If loss barely moves |
|---|---|---|---|
| Learning rate | 1e-3 (AdamW) | ÷ 10 | × 3–10 |
| Batch size | 64 | ÷ 2 (also fixes OOM) | × 2 (faster/epoch) |
| Weight decay | 1e-4 | — | — |
| Epochs | until val loss plateaus | — | train longer / bigger model |
| Symptom | Check first |
|---|---|
| Shape error | Print shapes at each stage; read the message literally |
| Device mismatch | One device var; .to(device) on model + every batch |
| Weird loss, no crash | Missing zero_grad(); sigmoid/softmax before loss |
| NaN loss | LR too high (÷10); check for log(0)/div-by-zero |
| CUDA OOM | Halve batch size; gradient accumulation |
| Noisy test accuracy | Missing model.eval() + torch.no_grad() |
| Slow training | num_workers, pin_memory, profile samples/sec |
| "Can it learn at all?" | One-batch overfit test |
When a shape error strikes, walk this tree:
Error mentions shapes (AxB) vs (CxD)?
├─ In a Linear layer → does input's last dim == layer's in_features?
│ └─ No → fix the layer definition or the previous layer's out_features
├─ In a loss function → do output and target shapes match the loss's contract?
│ └─ Check the "Which loss / optimizer when" table above (logits vs indices!)
├─ In torch.cat/stack → are all non-concat dims equal? (cat) / all dims equal? (stack)
│ └─ No → you probably want the other one, or a reshape first
├─ After a transpose/permute → is a later .view() failing?
│ └─ Yes → use .reshape() or .contiguous().view()
└─ Shapes "almost" match (e.g. (32,1) vs (32,)) → .squeeze()/.unsqueeze() to align
Print x.shape after every transformation while learning. Professionals do this too — they just delete the prints afterward.
This is Chapters 2–11 compressed into one file. Start every new project from it:
"""Minimal PyTorch training template — AstolixGen Learning Series, Book 11."""
import argparse, csv, json, os, random, time
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset, random_split
# ---------- setup ----------
def set_seed(seed=42):
random.seed(seed); np.random.seed(seed)
torch.manual_seed(seed); torch.cuda.manual_seed_all(seed)
def get_args():
p = argparse.ArgumentParser()
p.add_argument("--lr", type=float, default=1e-2)
p.add_argument("--batch-size", type=int, default=64)
p.add_argument("--epochs", type=int, default=25)
p.add_argument("--seed", type=int, default=42)
p.add_argument("--run-dir", default=None)
return p.parse_args()
# ---------- model ----------
class MLP(nn.Module):
def __init__(self, in_dim=8, hidden=64, out_dim=3):
super().__init__()
self.net = nn.Sequential(nn.Linear(in_dim, hidden), nn.ReLU(),
nn.Linear(hidden, hidden), nn.ReLU(),
nn.Linear(hidden, out_dim))
def forward(self, x):
return self.net(x)
# ---------- loop ----------
def run_epoch(model, loader, loss_fn, device, optimizer=None):
train = optimizer is not None
model.train(train)
tot_loss, correct, total = 0.0, 0, 0
ctx = torch.enable_grad() if train else torch.no_grad()
with ctx:
for xb, yb in loader:
xb, yb = xb.to(device), yb.to(device)
if train: optimizer.zero_grad()
out = model(xb)
loss = loss_fn(out, yb)
if train:
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
tot_loss += loss.item() * xb.size(0)
correct += (out.argmax(1) == yb).sum().item()
total += xb.size(0)
return tot_loss / total, correct / total
def main():
args = get_args(); set_seed(args.seed)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
run_dir = args.run_dir or f"runs/{time.strftime('%Y%m%d_%H%M%S')}"
os.makedirs(f"{run_dir}/checkpoints", exist_ok=True)
json.dump(vars(args), open(f"{run_dir}/config.json", "w"), indent=2)
torch.manual_seed(args.seed)
X = torch.randn(3000, 8); y = (X[:, :3].argmax(1))
tr, va = random_split(TensorDataset(X, y), [2400, 600],
generator=torch.Generator().manual_seed(args.seed))
tr_loader = DataLoader(tr, batch_size=args.batch_size, shuffle=True)
va_loader = DataLoader(va, batch_size=256)
model = MLP().to(device)
loss_fn = nn.CrossEntropyLoss()
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4)
best, wait = float("inf"), 0
with open(f"{run_dir}/metrics.csv", "w", newline="") as f:
w = csv.writer(f); w.writerow(["epoch", "train_loss", "train_acc", "val_loss", "val_acc"])
for epoch in range(1, args.epochs + 1):
tr_l, tr_a = run_epoch(model, tr_loader, loss_fn, device, opt)
va_l, va_a = run_epoch(model, va_loader, loss_fn, device)
w.writerow([epoch, f"{tr_l:.4f}", f"{tr_a:.4f}", f"{va_l:.4f}", f"{va_a:.4f}"])
print(f"ep {epoch:3d} train {tr_l:.4f}/{tr_a:.3f} val {va_l:.4f}/{va_a:.3f}")
torch.save({"epoch": epoch, "model": model.state_dict(),
"opt": opt.state_dict(), "best": best},
f"{run_dir}/checkpoints/last.pt")
if va_l < best:
best, wait = va_l, 0
torch.save(model.state_dict(), f"{run_dir}/checkpoints/best_model.pt")
else:
wait += 1
if wait >= 10:
print("early stopping"); break
json.dump({"best_val_loss": best, "seed": args.seed,
"torch": torch.__version__}, open(f"{run_dir}/summary.json", "w"), indent=2)
print("done ->", run_dir)
if __name__ == "__main__":
main()
Run it: python template.py --lr 1e-2 --epochs 25. It includes seeds, configs-as-flags, CSV logging, rolling + best checkpoints, early stopping, gradient clipping, and device handling — the whole book in 100 lines.
When reading a paper with code, fill this in. If you can't fill a row, that's what to ask the authors (or what to figure out next):
| Paper says | Code has | This book's chapter |
|---|---|---|
| Architecture diagram | nn.Module class in model.py |
Ch. 5 |
| "Trained with cross-entropy" | loss_fn = nn.CrossEntropyLoss() |
Ch. 6 |
| "Adam, lr 3e-4" | torch.optim.AdamW(params, lr=3e-4) |
Ch. 6 |
| "Batch size 256, 100 epochs" | DataLoader(..., batch_size=256), epoch loop |
Ch. 7/8 |
| Dataset description | Dataset class + transforms |
Ch. 8 |
| "Best checkpoint selected by validation" | best_model.pt logic |
Ch. 9 |
| Reported numbers | summary.json / results table |
Ch. 11 |
| "Code available" | Tagged git commit + README | Ch. 11 |
If you know NumPy, you know most tensor ops — the names transfer almost directly:
| NumPy | PyTorch | Notes |
|---|---|---|
np.array([...]) |
torch.tensor([...]) |
Same idea |
np.zeros((2,3)) |
torch.zeros(2, 3) |
Tuple optional in torch |
np.random.randn(2,3) |
torch.randn(2, 3) |
Same distribution |
a.reshape(3, 4) |
a.reshape(3, 4) |
Identical |
a.T |
a.T (2-D) / a.permute(...) (N-D) |
permute generalizes transpose |
a + b (broadcast) |
a + b (broadcast) |
Same rules |
a @ b |
a @ b / torch.matmul(a, b) |
Same |
a * b |
a * b |
Element-wise in both |
np.mean(a, axis=0) |
a.mean(dim=0) |
axis → dim |
np.concatenate([a,b], axis=0) |
torch.cat([a, b], dim=0) |
Same idea |
np.stack([a,b]) |
torch.stack([a, b]) |
Same idea |
a[1:3, :] |
a[1:3, :] |
Identical slicing |
a[a > 0] |
a[a > 0] |
Identical boolean masking |
np.argmax(a, axis=1) |
a.argmax(dim=1) |
Same |
The mental model: PyTorch tensors are NumPy arrays with .to(device) and autograd. When stuck, ask "how would I do this in NumPy?" — the answer usually works verbatim.
A quick-scan table of the exact error strings you'll meet, what they mean, and the one-line fix. (Chapter 10 explains each in depth.)
| Error message (key phrase) | Meaning | Fix |
|---|---|---|
mat1 and mat2 shapes cannot be multiplied (AxB and CxD) |
Linear layer got wrong input features | Match layer in_features to data's last dim |
Expected all tensors to be on the same device |
Model/data split across CPU/GPU | One device var; .to(device) everywhere |
expected scalar type Long but found Float (or reverse) |
Target dtype wrong for the loss | .long() for CE targets; .float() for BCE/MSE |
Expected target size (N,), got (N, 1) |
Target has an extra dim | .squeeze() the target |
grad can be implicitly created only for scalar outputs |
.backward() on non-scalar |
Reduce to scalar first (your loss does this) |
one of the variables needed for gradient computation has been modified by an inplace operation |
In-place op corrupted the graph | Use out-of-place ops; avoid inplace=True while debugging |
CUDA out of memory |
Exceeded GPU RAM | Halve batch size; gradient accumulation |
view size is not compatible with input tensor's size and stride |
view after transpose/permute |
Use .reshape() or .contiguous().view() |
DataLoader worker exited unexpectedly |
Multiprocessing failure | Debug with num_workers=0 first |
stack expects each tensor to be equal size |
Variable-size samples in default collate | Write a custom collate_fn (Ch. 8) |
Missing key(s) in state_dict / Unexpected key(s) |
Architecture/checkpoint mismatch | Recreate exact architecture; or strict=False deliberately |
Attempting to deserialize object on a CUDA device |
GPU checkpoint on CPU-only machine | torch.load(path, map_location="cpu") |
Loss is nan (no exception) |
Divergence or bad data | ÷10 LR; check data for NaNs; NaN-detect in loop |
These are well-known, maintained resources worth your time after this book — all real, all free or widely available:
Prefer primary sources (official docs, the paper itself, the authors' repo) over random tutorials — the ecosystem moves fast and third-party posts decay.
[1] A. Paszke et al., "PyTorch: An imperative style, high-performance deep learning library," in Advances in Neural Information Processing Systems 32 (NeurIPS 2019), Vancouver, Canada, 2019.
[2] D. P. Kingma and J. Ba, "Adam: A method for stochastic optimization," in Proc. 3rd Int. Conf. Learning Representations (ICLR 2015), San Diego, CA, USA, 2015.
[3] D. E. Rumelhart, G. E. Hinton, and R. J. Williams, "Learning representations by back-propagating errors," Nature, vol. 323, no. 6088, pp. 533–536, 1986.
[4] Y. LeCun, Y. Bengio, and G. Hinton, "Deep learning," Nature, vol. 521, no. 7553, pp. 436–444, 2015.
[5] A. Krizhevsky, I. Sutskever, and G. E. Hinton, "ImageNet classification with deep convolutional neural networks," in Advances in Neural Information Processing Systems 25 (NeurIPS 2012), Lake Tahoe, NV, USA, 2012.
[6] K. He, X. Zhang, S. Ren, and J. Sun, "Deep residual learning for image recognition," in Proc. IEEE Conf. Computer Vision and Pattern Recognition (CVPR 2016), Las Vegas, NV, USA, 2016, pp. 770–778.
[7] S. Ioffe and C. Szegedy, "Batch normalization: Accelerating deep network training by reducing internal covariate shift," in Proc. 32nd Int. Conf. Machine Learning (ICML 2015), Lille, France, 2015, pp. 448–456.
[8] N. Srivastava, G. Hinton, A. Krizhevsky, I. Sutskever, and R. Salakhutdinov, "Dropout: A simple way to prevent neural networks from overfitting," J. Machine Learning Research, vol. 15, no. 1, pp. 1929–1958, 2014.
[9] X. Glorot and Y. Bengio, "Understanding the difficulty of training deep feedforward neural networks," in Proc. 13th Int. Conf. Artificial Intelligence and Statistics (AISTATS 2010), Sardinia, Italy, 2010, pp. 249–256.
[10] I. Loshchilov and F. Hutter, "Decoupled weight decay regularization," in Proc. 7th Int. Conf. Learning Representations (ICLR 2019), New Orleans, LA, USA, 2019.
[11] H. Robbins and S. Monro, "A stochastic approximation method," Annals of Mathematical Statistics, vol. 22, no. 3, pp. 400–407, 1951.
[12] A. Vaswani et al., "Attention is all you need," in Advances in Neural Information Processing Systems 30 (NeurIPS 2017), Long Beach, CA, USA, 2017.
.backward()).__len__ and __getitem__ that provides access to individual training samples.cuda:0).float32, int64); must be consistent across operations.max(0, x); the default hidden-layer activation function.best_model.pt: weights with the best validation score (for reporting); last.pt: latest state (for resuming).(16, 10). Without Python loops: compute per-column means, subtract them, then verify the result has (near-)zero column means. Print shapes at each step.(4, 1) + (3,), (5, 4, 1) * (4, 7), (2, 3) + (2, 1, 3). Then verify with code. For any that surprise you, write down the broadcasting rule that explains them.y = (x1 * x2 + x3)^2 with x = [2.0, 3.0, 1.0], compute the gradients analytically on paper, then verify with PyTorch autograd. They must match.(20 → 64 → 64 → 5) as an nn.Module subclass. Count its parameters with the one-liner from Chapter 5 and verify the count by hand calculation.CrossEntropyLoss wrong here? Implement a 50-line training script proving your choice works on synthetic data.zero_grad()) and document how the symptom differs. Write up both runs as a one-page lab note.Dataset class for a small real dataset of your choice (a CSV you create, or MNIST via torchvision). Include a seeded train/val split, correct dtypes in __getitem__, and train the Chapter 7 classifier on it. Log per-epoch metrics to a CSV file.best_model.pt, git commit). Write the summary.json you would paste into a paper's results table, including PyTorch version and seed.End of Book 11 — Deep Learning with PyTorch: First Steps AstolixGen Learning Series · 50 books · written for researcher students