Ch. 12
CNNs & Computer Vision
Convolution as feature detection, LeNet → ResNet → ViT, the texture-bias problem, adversarial examples.
A 3x3 A small grid of numbers that slides across an image, at each spot multiplying its numbers by the patch underneath and adding them into one number.Full glossary → kernel has nine weights. A fully-connected layer between two 224x224 RGB images has about twenty-two billion. ConvNets win because they refuse to learn the same edge detector ten thousand times in ten thousand spatial locations. They learn it once and apply it everywhere. Every architectural trick from LeNet through ResNet through Vision Transformers is a different answer to the same question: what is the right A built-in assumption that nudges a model toward certain kinds of patterns.Full glossary → for two-dimensional data when you want compute to scale with content, not with image size? By the end of this chapter you will have built convolutions, Shrinking an image grid by replacing each small patch with a single summary number.Full glossary →, A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → norm, and a small ResNet by hand in NumPy, retrained ResNet-18 in PyTorch, and looked inside the first three layers using the actual curve-detector circuits that Olah's team labelled by hand.
FIG 12.1 · Learning outcomes
By the end of this chapter you will be able to:
- Implement 2D convolution from scratch in pure NumPy, with
stride,padding, and multi-channel inputs, in under 40 lines. - Compute the output shape of any conv or pool layer in your head: $\lfloor (W - F + 2P)/S \rfloor + 1$.
- Build LeNet, AlexNet, VGG, GoogLeNet's Inception block, and a small ResNet from
nn.Conv2dprimitives. - Explain why skip connections changed everything about how deep we can train, and why the loss landscape changes shape when you add them.
- Train ResNet-18 to >93% on CIFAR-10 in under 30 minutes on a single T4.
- Read a feature visualization grid from Distill's circuits work and say what the unit "detects" (curves, parallel lines, dog snouts).
- Articulate three places a CNN's translation-invariance assumption breaks (adversarial patches, texture bias, distribution shift) and what each one looks like.
- Build a U-Net for binary segmentation in 100 lines.
FIG 12.2 · What you need first
- Ch 9 — Neural Networks Introduction — you need to know what a linear layer, ReLU, and backprop are. The whole chapter assumes you can read
nn.Linearwithout flinching. - Ch 10 — PyTorch — every code block uses
nn.Module,DataLoader, andoptimizer.step. If those words are noise, work another chapter first. - Ch 11 — Training Deep Neural Networks — batch norm, dropout, learning-rate schedules, weight decay. We will use all of them. The chapter explains where they sit inside a CNN, not what they are.
If you skipped another chapter: you can probably get through the first six sections (convolution, pooling, the receptive field, LeNet, AlexNet, VGG). The moment we hit batch norm in ResNet, come back.
FIG 12.3.1
Convolution as feature detection
A A small grid of numbers that slides across an image, at each spot multiplying its numbers by the patch underneath and adding them into one number.Full glossary → is a sliding dot product. You take a small A small grid of weights that slides across an image to spot a particular pattern.Full glossary → (often 3x3 or 5x5), slide it across an input image, and at each position compute the dot product of the filter weights with the underlying patch. The result is a new 2D array called a The grid of responses you get after sliding a filter over an image, bright where the pattern was found.Full glossary →. The filter is shared across every spatial location, which is what gives the convolution its first defining property: translation equivariance. Shift the input one pixel right; the output also shifts one pixel right. The network has not had to learn the same pattern twice.
The second defining property is locality. Each output pixel only depends on a small neighbourhood of input pixels. Far-apart pixels can only interact by stacking multiple layers, which is how the The patch of the original image that a single deep unit is actually looking at.Full glossary → grows. We will come back to this.
The third property is One of the model's internal numbers that gets adjusted as it learns.Full glossary → sharing, which is locality plus translation equivariance taken seriously. A 3x3 filter on a 224x224 input is 9 weights, regardless of image size. A fully-connected layer on the same input would have 50,176 weights per output unit. This is the prior that makes vision tractable at scale.
A single conv layer in PyTorch takes a multi-One of several stacked grids of numbers in an image, each tracking a different kind of pattern.Full glossary → input and produces a multi-channel output . The A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → has shape . Each output channel is its own filter, looking at all input channels at once.
nn.Conv2d vs. from scratch
DL primitiveconv = nn.Conv2d(in_channels=3, out_channels=32, kernel_size=3, stride=1, padding=1)
out = conv(x) # x: (N, 3, H, W) -> (N, 32, H, W)def conv2d_naive(x, w, b, stride=1, pad=0):
# x: (N, C_in, H, W). w: (C_out, C_in, kH, kW). b: (C_out,).
N, C_in, H, W = x.shape
C_out, _, kH, kW = w.shape
H_out = (H + 2*pad - kH) // stride + 1
W_out = (W + 2*pad - kW) // stride + 1
x_padded = np.pad(x, ((0,0),(0,0),(pad,pad),(pad,pad)))
out = np.zeros((N, C_out, H_out, W_out))
for n in range(N):
for c_out in range(C_out):
for i in range(H_out):
for j in range(W_out):
patch = x_padded[n, :, i*stride:i*stride+kH, j*stride:j*stride+kW]
out[n, c_out, i, j] = np.sum(patch * w[c_out]) + b[c_out]
return outfrom scratch: lab/solution.py: BasicBlock.conv1 (nn.Conv2d); reference loop in draft.md §1: conv2d_naive
- 1
conv = nn.Conv2d(in_channels=C_in, out_channels=C_out, kernel_size=k)w of shape (C_out, C_in, kH, kW) and b of shape (C_out,) — the learned weight/bias tensors - 2
conv(x) over the whole (N,C,H,W) batch in one cuDNN/im2col callthe quadruple `for n / for c_out / for i / for j` loop computing every output pixel - 3
padding=P argumentnp.pad(x, ...(pad,pad)(pad,pad)) zero-padding the H and W borders before the slide - 4
stride=S argument and output-shape arithmeticH_out = (H + 2*pad - kH)//stride + 1 and the i*stride/j*stride patch indexing - 5
summing over all input channels per output channelnp.sum(patch * w[c_out]) where patch is x_padded[n, :, ...] across all C_in at once
What the one call hides
- im2col + a single big matmul (and the cuDNN-selected algorithm) — the library never runs Python loops; the naive version is O(N*C_out*H*W*C_in*k^2) pure Python.
- It's cross-correlation, not flipped 'mathematical' convolution — both library and scratch skip the kernel flip, matching ML convention.
- Kaiming weight init and a learnable bias the library creates automatically; conv2d_naive takes w and b as given.
- Autograd: nn.Conv2d wires up the backward pass (grads w.r.t. input, weight, bias); the NumPy version is forward-only.
- groups, dilation, and 'same'-string padding options the one-liner exposes but conv2d_naive does not model.
- Gotcha: PyTorch's default padding is 0, not 'same' — spatial dims silently shrink each layer and you only find out at the flatten/FC head.
- Gotcha: kernel_size and the weight shape (C_out, C_in, kH, kW) are easy to transpose mentally; getting C_in/C_out backwards is a classic first-CNN bug.
- Gotcha: The naive loop is for understanding only — on real data it is thousands of times slower than nn.Conv2d; read it once, then never write it again.
Prefer nn.Conv2d in real code; the loop version is purely to internalize that a conv is a sliding multi-channel dot product with output size floor((H+2P-k)/S)+1, and to demystify im2col before you ever profile or write a custom kernel.
On the job: You never hand-roll the loop; you pick channels/kernel/stride/padding, chain Conv2d-BN-ReLU blocks, and reason about the output-shape formula so the flatten dimension into your FC head is right.
That nested loop is what nn.Conv2d does, with all the loops fused inside a cuDNN kernel and the patch-extraction step done via im2col so the inner operation becomes a single matmul. Read this version once, then never write it again.
FIG 12.3.2
Padding and strides
Two parameters you set on every conv layer. Both come from one question: how should the output shape compare to the input shape?
Adding a border of zeros around an image so it doesn't shrink when a filter slides over it.Full glossary → is zero-padding the input borders before convolving. Without padding, a 3x3 A small grid of weights that slides across an image to spot a particular pattern.Full glossary → on a 5x5 input gives a 3x3 output. You lose two pixels on each side per layer. Stack ten layers and you lose twenty pixels. With padding for an odd kernel size , the output keeps the same spatial size as the input. This is called "same" padding. The alternative is "valid" (no padding).
How many pixels a sliding filter jumps with each step across an image.Full glossary → is how many pixels the filter jumps each step. Stride 1 is dense. Stride 2 halves the spatial dimensions, which is how you downsample without a Shrinking an image grid by replacing each small patch with a single summary number.Full glossary → layer (modern ResNets do this).
The output shape formula, which you will use approximately five hundred times in this chapter:
Memorise it. Print it on a sticky note.
# Same padding, stride 1: keeps spatial dims
nn.Conv2d(64, 128, kernel_size=3, padding=1)
# Stride-2 conv: halves spatial dims, no pool needed
nn.Conv2d(64, 128, kernel_size=3, stride=2, padding=1)
# Valid: shrinks the output
nn.Conv2d(64, 128, kernel_size=3, padding=0)FIG 12.3.3
Pooling and translation invariance
Shrinking an image grid by replacing each small patch with a single summary number.Full glossary → is a fixed (non-learned) operation that downsamples a The grid of responses you get after sliding a filter over an image, bright where the pattern was found.Full glossary → by taking the max or the mean over a small window. The standard choice is 2x2 max pooling with How many pixels a sliding filter jumps with each step across an image.Full glossary → 2, which halves both spatial dimensions and keeps only the highest activation per window.
Why max specifically? Because the highest activation is the strongest evidence that a One piece of information about an example that the model looks at when making a guess.Full glossary → is present somewhere in that window. The exact spatial location is discarded. This is the move from translation equivariance (convolutions) to a degree of translation invariance. A cat shifted three pixels still triggers the same post-pool feature map.
Pooling is also a One of the model's internal numbers that gets adjusted as it learns.Full glossary →-free shrink: no weights, just a deterministic reduction. Modern architectures have started replacing max pooling with strided A small grid of numbers that slides across an image, at each spot multiplying its numbers by the patch underneath and adding them into one number.Full glossary → (ResNets, the entire post-2015 lineage), on the grounds that learned downsampling beats fixed downsampling. Both are still in production code.
nn.MaxPool2d vs. from scratch
DL primitivepool = nn.MaxPool2d(kernel_size=2, stride=2)
out = pool(x) # (N, C, H, W) -> (N, C, H/2, W/2)def max_pool_2d(x, k=2, stride=2):
N, C, H, W = x.shape
H_out, W_out = (H - k)//stride + 1, (W - k)//stride + 1
out = np.zeros((N, C, H_out, W_out))
for i in range(H_out):
for j in range(W_out):
patch = x[:, :, i*stride:i*stride+k, j*stride:j*stride+k]
out[:, :, i, j] = patch.reshape(N, C, -1).max(axis=-1)
return outfrom scratch: draft.md §3 Pooling and translation invariance: max_pool_2d
- 1
nn.MaxPool2d(kernel_size=k, stride=stride)the (k, stride) used to slice each window and the (H-k)//stride+1 output-size formula - 2
pool(x) reducing each window to its maxpatch.reshape(N, C, -1).max(axis=-1) — flatten the k*k window and take the per-(N,C) maximum - 3
per-channel, per-batch independence (no mixing across C or N)the max is taken only over the last (spatial) axis, leaving N and C untouched - 4
the sliding-window stepthe `for i / for j` loop with i*stride / j*stride window origins
What the one call hides
- The argmax indices stored for the backward pass (max-pool gradient routes only to the winning element); the NumPy version is forward-only.
- padding and dilation options, and the ceil_mode flag that changes output-size rounding.
- Vectorized/cuDNN execution instead of a Python double loop.
- That it's parameter-free — no weights to learn, just a fixed reduction (true of both, but the library dresses it up as a 'layer' with no init).
- Gotcha: Max pool discards exact spatial location; for segmentation/detection you must keep or recover it (U-Net skips, transposed conv).
- Gotcha: Default stride of nn.MaxPool2d equals kernel_size (not 1); people set kernel_size=2 expecting stride 1 and get a 2x downsample.
- Gotcha: Odd input sizes with stride 2 drop a border row/column (floor division) unless you pad or set ceil_mode.
Use nn.MaxPool2d (or strided convs, which modern ResNets prefer) in production; the scratch version shows pooling is just a fixed windowed max — no parameters — and why it trades translation equivariance for a bit of invariance.
On the job: You mostly type nn.MaxPool2d(2) (or replace it with a stride-2 conv) and double-check the resulting spatial size; the loop is only ever for teaching or debugging an odd-size off-by-one.
FIG 12.3.4
The receptive field
The The patch of the original image that a single deep unit is actually looking at.Full glossary → of a unit is the region of the input it depends on. For a single 3x3 conv layer, a unit's receptive field is 3x3. For two stacked 3x3 layers, each unit depends on a 5x5 region (the second layer sees a 3x3 grid of units, each of which sees its own 3x3 input patch, which overlap). For three stacked 3x3 layers, the receptive field is 7x7. Add a 2x2 How many pixels a sliding filter jumps with each step across an image.Full glossary →-2 pool and the next layer's receptive field is doubled in each dimension.
This is one of the few CNN concepts where the arithmetic is load-bearing. Two stacked 3x3 convs cover the same receptive field as one 5x5 conv, but with fewer parameters (18 versus 25) and one more non-linearity in between. This is the entire argument behind VGG's design choice to use only 3x3 filters.
A useful piece of intuition: deep layers in a trained CNN look at large image regions, but they don't literally attend uniformly across that region. The effective receptive field is much smaller than the theoretical one, concentrated near the center. This is empirical (Luo et al. 2016) and matters when you start chasing why your model misses small or peripheral objects.
# Compute receptive field after a stack of layers
def receptive_field(layers: list[tuple[int, int]]) -> int:
"""layers: list of (kernel_size, stride). Returns RF in pixels."""
rf, jump = 1, 1
for k, s in layers:
rf += (k - 1) * jump
jump *= s
return rf
# VGG-style first block: 3x3 conv, 3x3 conv, 2x2 pool stride 2
print(receptive_field([(3,1), (3,1), (2,2)])) # 6
# After two such blocks
print(receptive_field([(3,1), (3,1), (2,2)] * 2)) # 16FIG 12.3.5
LeNet: the first real CNN
LeNet-5 (LeCun et al. 1998) is the architecture that introduced approximately every idea in this chapter, two decades before they became dominant. Conv layers, average Shrinking an image grid by replacing each small patch with a single summary number.Full glossary →, fully-connected head, The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary →. It worked on the MNIST digits at 99% The share of guesses the model got right out of all its guesses.Full glossary → in 1998 and runs on a CPU in a few minutes today. Karpathy's "Recurse Center talk on LeCun 1989" (24-founder-blogs/karpathy-lecun1989) is worth reading after this section. He literally re-ran the 1989 paper's experiment on modern hardware and reproduced the results.
The architecture, in modern PyTorch:
class LeNet(nn.Module):
def __init__(self, n_classes: int = 10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Tanh(),
nn.AvgPool2d(2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), nn.Tanh(),
nn.AvgPool2d(2, stride=2),
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(16 * 5 * 5, 120), nn.Tanh(),
nn.Linear(120, 84), nn.Tanh(),
nn.Linear(84, n_classes),
)
def forward(self, x):
return self.classifier(self.features(x))Read it slowly. Two conv-pool blocks, three fully-connected layers, tanh nonlinearities. About 60k parameters. The reason this didn't take over the world in 1998 was hardware: training took weeks, datasets were tiny, and the rest of computer vision was busy on SVMs. The chapter on LeCun's original 1989 paper is worth reading once for the historical clarity.
FIG 12.3.6
AlexNet: the moment everything changed
AlexNet (Krizhevsky et al. 2012) won ImageNet by a margin so large that nobody outside the deep-learning community had calibrated for it. Top-5 error dropped from 26% to 16% in a single year. Everyone retrained their priors that week.
The architectural changes from LeNet were not philosophical. They were practical:
- The most common bend in neural networks: it keeps positive numbers as they are and turns any negative number into zero.Full glossary → instead of tanh, which removed the vanishing-A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → ceiling on depth and trained ~6x faster
- A training trick where the model randomly switches off some of its own pieces each pass, so it can't lean too hard on any one of them.Full glossary → in the fully-connected layers to combat When a model memorizes the quirks and flukes of its study examples instead of the real pattern, so it flops on anything new.Full glossary →
- Two GPUs in parallel because no single GPU at the time had enough memory for the model
- Aggressive Making extra training examples by tweaking the ones you have, flipping, cropping, or rotating images.Full glossary → (random crops, horizontal flips, PCA-based colour jitter)
- Local response normalisation which we have since stopped using (A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → norm replaced it)
The architecture is a deeper LeNet: five conv layers, three FC layers, max pool, ReLU everywhere.
class AlexNet(nn.Module):
def __init__(self, n_classes: int = 1000):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 96, kernel_size=11, stride=4, padding=2), nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2),
nn.Conv2d(96, 256, kernel_size=5, padding=2), nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2),
nn.Conv2d(256, 384, kernel_size=3, padding=1), nn.ReLU(inplace=True),
nn.Conv2d(384, 384, kernel_size=3, padding=1), nn.ReLU(inplace=True),
nn.Conv2d(384, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2),
)
self.classifier = nn.Sequential(
nn.Dropout(),
nn.Linear(256 * 6 * 6, 4096), nn.ReLU(inplace=True),
nn.Dropout(),
nn.Linear(4096, 4096), nn.ReLU(inplace=True),
nn.Linear(4096, n_classes),
)
def forward(self, x):
return self.classifier(self.features(x).flatten(1))About 60M parameters, most of them in those two 4096-dim FC layers. The first conv kernel is 11x11 with How many pixels a sliding filter jumps with each step across an image.Full glossary → 4, which is a hand-crafted way of subsampling the 224x224 input quickly. VGG would later show that you can replace it with a stack of 3x3 convs and do better.
FIG 12.3.7
VGG: simplicity wins
VGG (Simonyan and Zisserman 2014) is the architecture that proved the deep-learning intuition that "deeper is better, with simpler primitives". The whole network is 3x3 conv layers and 2x2 max pools. No 5x5, no 7x7, no fancy first-layer trick. Two-to-three 3x3 convs, then pool, then double the channels, repeat. VGG-16 has 16 A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → layers; VGG-19 has 19.
The principled argument: two stacked 3x3 convs have the same The patch of the original image that a single deep unit is actually looking at.Full glossary → as one 5x5 conv, but with params per input/output One of several stacked grids of numbers in an image, each tracking a different kind of pattern.Full glossary → pair versus . Three stacked 3x3 convs equal one 7x7, with 27 versus 49 params. You get fewer parameters and more non-linearities for the same receptive field. The trade is more compute time (more layers, more activations to store), but better The share of guesses the model got right out of all its guesses.Full glossary →.
def vgg_block(n_convs: int, in_c: int, out_c: int) -> nn.Sequential:
layers = []
for _ in range(n_convs):
layers.append(nn.Conv2d(in_c, out_c, kernel_size=3, padding=1))
layers.append(nn.ReLU(inplace=True))
in_c = out_c
layers.append(nn.MaxPool2d(kernel_size=2, stride=2))
return nn.Sequential(*layers)
class VGG16(nn.Module):
def __init__(self, n_classes: int = 1000):
super().__init__()
cfg = [(2, 64), (2, 128), (3, 256), (3, 512), (3, 512)]
in_c = 3
blocks = []
for n, out_c in cfg:
blocks.append(vgg_block(n, in_c, out_c))
in_c = out_c
self.features = nn.Sequential(*blocks)
self.classifier = nn.Sequential(
nn.Flatten(), nn.Linear(512 * 7 * 7, 4096), nn.ReLU(inplace=True), nn.Dropout(),
nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Dropout(),
nn.Linear(4096, n_classes),
)
def forward(self, x):
return self.classifier(self.features(x))VGG is ≈138M parameters. Most of them, again, in the FC head. The conv stack is only ≈15M. Modern architectures replace the giant FC head with global average Shrinking an image grid by replacing each small patch with a single summary number.Full glossary → to a single conv classifier, dropping the One of the model's internal numbers that gets adjusted as it learns.Full glossary → count by 10x with no accuracy hit. We will see this in ResNet.
FIG 12.3.8
GoogLeNet and the Inception block
GoogLeNet (Szegedy et al. 2014) introduced two ideas that have not gone away. First: instead of choosing one A small grid of weights that slides across an image to spot a particular pattern.Full glossary → size per layer, run several in parallel and concatenate the results. Second: use 1x1 convolutions to mix channels and cheaply reduce dimensionality.
A 1x1 A small grid of numbers that slides across an image, at each spot multiplying its numbers by the patch underneath and adding them into one number.Full glossary → is exactly what it sounds like: a filter of size 1x1xC_in that produces one output One of several stacked grids of numbers in an image, each tracking a different kind of pattern.Full glossary → by linearly combining the input channels at a single spatial location. It has zero The patch of the original image that a single deep unit is actually looking at.Full glossary → beyond a single pixel. What it does is channel mixing. It is also the only way to cheaply reduce the channel count without losing spatial information.
The Inception module computes four parallel paths from the same input: a 1x1 conv, a 3x3 conv (preceded by a 1x1 A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary →), a 5x5 conv (preceded by a 1x1 bottleneck), and a 3x3 max pool followed by a 1x1 conv. All four outputs are concatenated along the channel dimension. The network chooses, layer by layer, how much of each filter size it wants.
class InceptionBlock(nn.Module):
def __init__(self, in_c, c1, c2_reduce, c2, c3_reduce, c3, c4):
super().__init__()
# 1x1 path
self.p1 = nn.Conv2d(in_c, c1, 1)
# 1x1 -> 3x3 path
self.p2 = nn.Sequential(
nn.Conv2d(in_c, c2_reduce, 1), nn.ReLU(inplace=True),
nn.Conv2d(c2_reduce, c2, 3, padding=1),
)
# 1x1 -> 5x5 path
self.p3 = nn.Sequential(
nn.Conv2d(in_c, c3_reduce, 1), nn.ReLU(inplace=True),
nn.Conv2d(c3_reduce, c3, 5, padding=2),
)
# 3x3 pool -> 1x1 path
self.p4 = nn.Sequential(
nn.MaxPool2d(3, stride=1, padding=1),
nn.Conv2d(in_c, c4, 1),
)
def forward(self, x):
return torch.cat([F.relu(self.p1(x)), F.relu(self.p2(x)),
F.relu(self.p3(x)), F.relu(self.p4(x))], dim=1)GoogLeNet was 22 layers deep but had fewer parameters than AlexNet (~5M versus 60M), because it ditched the giant FC head and used global average Shrinking an image grid by replacing each small patch with a single summary number.Full glossary →. It also introduced auxiliary classifiers attached to intermediate layers, a trick for fighting vanishing gradients in deep networks before A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → norm existed. Once batch norm landed, auxiliary classifiers became unnecessary and quietly disappeared.
FIG 12.3.9
Batch normalisation in CNNs
A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → normalisation (Ioffe and Szegedy 2015) is what made very deep networks trainable. In a CNN, batch norm operates on the (N, C, H, W) A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → by computing per-One of several stacked grids of numbers in an image, each tracking a different kind of pattern.Full glossary → statistics across the batch and spatial dimensions. For each channel :
Two learned parameters per channel (, ). At Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → time, you use running estimates of and accumulated during training rather than batch statistics. This is the single most common bug in transferring a CNN to deployment: people forget to put the model in eval mode and batch norm uses the mini-batch's statistics, which during inference may be a batch of size 1 with degenerate variance.
# In a CNN block, batch norm goes between conv and ReLU
nn.Sequential(
nn.Conv2d(64, 128, 3, padding=1, bias=False), # bias=False because BN adds one
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
)FIG 12.3.10
ResNet and skip connections
In 2015, He et al. tried to train a 34-layer plain CNN and got a worse result than an 18-layer version. This was not When a model memorizes the quirks and flukes of its study examples instead of the real pattern, so it flops on anything new.Full glossary →. The 34-layer model had higher training error too. Something about depth itself was breaking the optimisation.
The fix was the residual block. Instead of learning directly, learn such that . The shortcut connection adds the input straight to the output, and the conv layers only have to model the residual — the difference from identity. This has a deep consequence: if the optimal function for a block is close to identity, The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary → can find it by pushing toward zero, which is easy. The 34-layer plain network was failing because gradient descent could not find identity mappings in the deeper layers. ResNet handed identity to it for free.
The standard ResNet block (for ResNet-18 and ResNet-34):
import torch.nn.functional as F
class BasicBlock(nn.Module):
expansion = 1
def __init__(self, in_c: int, out_c: int, stride: int = 1):
super().__init__()
self.conv1 = nn.Conv2d(in_c, out_c, 3, stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(out_c)
self.conv2 = nn.Conv2d(out_c, out_c, 3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_c)
# If shapes differ, project the shortcut
self.shortcut = nn.Identity()
if stride != 1 or in_c != out_c:
self.shortcut = nn.Sequential(
nn.Conv2d(in_c, out_c, 1, stride=stride, bias=False),
nn.BatchNorm2d(out_c),
)
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
return F.relu(out + self.shortcut(x))ResNet-50 and beyond use the A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary → block: 1x1 conv to reduce channels, 3x3 conv at the lower One of several stacked grids of numbers in an image, each tracking a different kind of pattern.Full glossary → count, 1x1 conv to expand back. Same idea, four times fewer FLOPs (floating-point operations, the count of multiply-and-add steps the Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary → actually executes; the notebook's Part 4 budgets them precisely) at high channel counts. The deepest practical ResNet is around 152 layers; beyond that, you hit diminishing returns and modern alternatives (DenseNet, EfficientNet, ConvNeXt) start to look better.
The The main running tally of information that flows through a deep model, with each layer reading from it and adding its bit back in.Full glossary → view of ResNets is interesting in its own right. Each block reads from the stream and writes a small correction back. The stream at depth is the sum of contributions from every block from 0 to . This is exactly the framing Anthropic later applied to transformer residual streams in 22-anthropic-recent/2021-framework-index §residual-stream. The two architectures rhyme more than people noticed at the time.
FIG 12.3.11
Squeeze-and-Excitation: attention before attention was cool
SE blocks (Hu et al. 2017) add a tiny A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → mechanism to a CNN block. After the conv layers, you take the The grid of responses you get after sliding a filter over an image, bright where the pattern was found.Full glossary → (N, C, H, W), globally average-pool to (N, C), pass through a two-layer MLP to (N, C) again, A function that takes any number and squeezes it into a single confidence between 0 and 1.Full glossary → the result, and multiply it back into the feature map One of several stacked grids of numbers in an image, each tracking a different kind of pattern.Full glossary →-wise. You are learning a per-channel gate that says "this channel is relevant for this input, that channel is not".
class SqueezeExcitation(nn.Module):
def __init__(self, channels: int, reduction: int = 16):
super().__init__()
self.pool = nn.AdaptiveAvgPool2d(1)
self.fc = nn.Sequential(
nn.Linear(channels, channels // reduction), nn.ReLU(inplace=True),
nn.Linear(channels // reduction, channels), nn.Sigmoid(),
)
def forward(self, x):
B, C, _, _ = x.shape
w = self.fc(self.pool(x).view(B, C)).view(B, C, 1, 1)
return x * wThis is input-dependent channel gating, not A calculation where each word gets a score for how related it is to every other word, then blends in the others according to those scores.Full glossary →. Like attention, it produces data-dependent weights and uses them to reweight features. Unlike transformer self-attention, it does not form queries and keys or compute pairwise interactions between positions/channels: global average Shrinking an image grid by replacing each small patch with a single summary number.Full glossary → compresses each channel, and an MLP emits one gate per channel. The analogy is useful at the level of dynamic reweighting, but the mechanisms are not equivalent. SE blocks gave ResNet a meaningful The share of guesses the model got right out of all its guesses.Full glossary → bump (winning ImageNet 2017) with minimal compute overhead. They are still used in production ConvNets.
FIG 12.3.12
What CNNs see: feature visualisation and circuits
This is the part where you stop treating the CNN as a black box. Olah, Mordvintsev and Schubert's work on 01-explorables/distill-feature-visualization and the follow-up Circuits thread (distill-circuits-early-vision, distill-circuits-curve-detectors, distill-circuits-zoom-in) showed that you can label individual units in a trained CNN by what they detect: Gabor-like edges at layer 1, textures at layer 2, curves and parallel lines at layer 3, dog snouts and car wheels at layer 5, semantic objects at layer 8.
Two techniques drive this. One piece of information about an example that the model looks at when making a guess.Full glossary → visualisation generates an input image that maximally activates a specific unit, by A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → ascent on the input pixels. Dataset examples find the images in the The batch of examples the model actually studies and learns from.Full glossary → that most activate a unit. Combine them and you get a hypothesis ("this unit fires for circles") plus the evidence ("here are the 9 training images that triggered it most"). When the two agree, you have an interpretation you can act on.
The Distill Circuits work goes one step further: it identifies families of units that work together (curve detectors at different orientations, parallel line detectors at different angles) and shows that they are connected by interpretable weights. Layer 3 curve detectors are constructed from layer 2 line detectors by literally adding curved templates. This is Working backward through a trained model to trace the exact steps that led to its answer.Full glossary → (mech-interp: reverse-engineering the algorithm a network has actually learned by reading its weights and activations, the subject of another chapter) before mech-interp had a name, on a vision model. The lessons transferred to transformers later.
# Sketch of feature visualisation via gradient ascent
def visualise_unit(model, layer_idx: int, channel: int, steps: int = 500):
x = torch.randn(1, 3, 224, 224, requires_grad=True)
optim = torch.optim.Adam([x], lr=0.05)
activations = {}
def hook(mod, inp, out):
activations['out'] = out
h = list(model.modules())[layer_idx].register_forward_hook(hook)
for _ in range(steps):
optim.zero_grad()
model(x)
# Negate: we want to MAXIMISE the activation
loss = -activations['out'][0, channel].mean()
loss.backward()
optim.step()
h.remove()
return x.detach()In practice you regularise this heavily (decorrelate the input, jitter, total-variation penalty) so the generated image is interpretable rather than a high-frequency adversarial pattern. See 01-explorables/distill-feature-visualization §regularization for the production recipe.
FIG 12.3.13
Object detection: YOLO and Faster R-CNN sketched
Classification asks "what is in this image". Detection asks "what is in this image and where is it". The bounding-box output makes the problem harder, but the architectural primitives are the same convs and pools.
Two families. Region-proposal methods (R-CNN, Fast R-CNN, Faster R-CNN) generate candidate regions first and then classify each region. They are accurate and slow. Single-shot methods (YOLO, SSD, RetinaNet) treat detection as a dense regression problem: the CNN predicts, at every spatial location on a downsampled The grid of responses you get after sliding a filter over an image, bright where the pattern was found.Full glossary →, "is there an object here, and what is its class and box". They are fast and competitive on The share of guesses the model got right out of all its guesses.Full glossary → once you add focal loss and FPN.
# YOLO-style head: at each cell, predict (x, y, w, h, objectness, class_logits)
class YOLOHead(nn.Module):
def __init__(self, in_c: int, n_anchors: int, n_classes: int):
super().__init__()
# 5 = x, y, w, h, objectness
self.out = nn.Conv2d(in_c, n_anchors * (5 + n_classes), kernel_size=1)
def forward(self, x):
return self.out(x)The actual training loss is intricate (anchor matching, A score for how well a predicted box overlaps the true box, from no overlap to a perfect match.Full glossary →-based box regression, hard-negative mining, focal loss for class imbalance), and we are not going to derive it here. The point for this chapter is structural: detection re-uses every CNN primitive you have built, and the architectural innovation is in what to predict at every spatial cell, not in new operations.
FIG 12.3.14
Semantic segmentation and U-Net
Labeling every single pixel in an image with what it belongs to.Full glossary → classifies every pixel. The output has the same spatial resolution as the input. The architectural challenge is that you cannot downsample everything away if you need pixel-level outputs back. U-Net (Ronneberger et al. 2015) solved this with a symmetric A two-part design where one half squeezes the input into a compact summary and the other half expands it into the output.Full glossary →: pool down to a low-resolution A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary →, then upsample back, and at each scale add a A shortcut that adds a layer's input directly to its output, so the layer only has to learn the change.Full glossary → from the encoder side to the decoder side. The skip connections carry high-frequency spatial information that the bottleneck would have destroyed.
class UNet(nn.Module):
def __init__(self, n_classes: int = 2):
super().__init__()
def conv_block(in_c, out_c):
return nn.Sequential(
nn.Conv2d(in_c, out_c, 3, padding=1), nn.ReLU(inplace=True),
nn.Conv2d(out_c, out_c, 3, padding=1), nn.ReLU(inplace=True),
)
self.down1 = conv_block(3, 64)
self.down2 = conv_block(64, 128)
self.down3 = conv_block(128, 256)
self.bottleneck = conv_block(256, 512)
self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2)
self.dec3 = conv_block(512, 256)
self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2)
self.dec2 = conv_block(256, 128)
self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2)
self.dec1 = conv_block(128, 64)
self.out = nn.Conv2d(64, n_classes, 1)
def forward(self, x):
d1 = self.down1(x)
d2 = self.down2(F.max_pool2d(d1, 2))
d3 = self.down3(F.max_pool2d(d2, 2))
b = self.bottleneck(F.max_pool2d(d3, 2))
u3 = self.dec3(torch.cat([self.up3(b), d3], dim=1))
u2 = self.dec2(torch.cat([self.up2(u3), d2], dim=1))
u1 = self.dec1(torch.cat([self.up1(u2), d1], dim=1))
return self.out(u1)That ConvTranspose2d is the upsampling operation: a "fractionally strided" or "transposed" A small grid of numbers that slides across an image, at each spot multiplying its numbers by the patch underneath and adding them into one number.Full glossary → that produces a larger output from a smaller input. It is the only new primitive in U-Net. Modern segmentation often replaces it with bilinear upsampling followed by a regular conv, which avoids checkerboard artifacts at the cost of one extra layer.
FIG 12.3.15
Vision transformers as the bridge
Vision transformers (ViT, Dosovitskiy et al. 2020) replaced the convolutional A built-in assumption that nudges a model toward certain kinds of patterns.Full glossary → with the transformer's A calculation where each word gets a score for how related it is to every other word, then blends in the others according to those scores.Full glossary →, where every patch can directly read from every other patch (the SE block in §11 did a 1D version of this over channels; ViT does it over the full set of patch tokens, which is what removes the hard-coded locality). The image is cut into fixed-size patches (typically 16x16), each patch is flattened and linearly projected into a A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary →, and the resulting sequence of patch tokens is fed to a standard transformer encoder. A learned [CLS] token's output embedding becomes the image's representation for classification.
The trade-off is well documented now. With enough data (JFT-300M scale), ViT outperforms ResNets on ImageNet. With ImageNet-1k alone (1.3M images), ViT underperforms ResNet because the convolutional inductive bias (locality, translation equivariance) does real work when data is scarce. The middle ground is hybrid models (ConvNeXt, Swin Transformer, MaxViT) that bring some convolutional locality back to the transformer.
class PatchEmbed(nn.Module):
"""Image -> sequence of patch tokens via a single strided conv."""
def __init__(self, img_size: int = 224, patch: int = 16, d_model: int = 768):
super().__init__()
self.proj = nn.Conv2d(3, d_model, kernel_size=patch, stride=patch)
self.n_patches = (img_size // patch) ** 2
def forward(self, x):
# x: (B, 3, H, W) -> (B, d_model, H/p, W/p) -> (B, n_patches, d_model)
return self.proj(x).flatten(2).transpose(1, 2)That single strided conv nn.Conv2d(3, d_model, kernel_size=16, stride=16) is the entire convolutional content of a ViT. Everything after it is the another chapter transformer architecture applied to image tokens.
This is the chapter's exit door. another chapter picks up here with the transformer details. The reason ViT works is that the transformer architecture is general enough that "image patches" and "tokens" are interchangeable, if you have enough data to learn the spatial relationships the A small grid of numbers that slides across an image, at each spot multiplying its numbers by the patch underneath and adding them into one number.Full glossary → would have hard-coded. We will come back to that "if".
FIG 12.4 · Safety lens · this chapter
CNNs have a famous vulnerability and a famous A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary →. They are also the architecture where mech-interp was first done at scale, which gives them the cleanest "what can go wrong, here's how we know" story in this curriculum.
Adversarial examples. A trained ImageNet classifier can be fooled into misclassifying a panda as a gibbon by adding imperceptible pixel-level noise (Szegedy et al. 2014, Goodfellow et al. 2015). Karpathy's own 24-founder-blogs/karpathy-breaking-convnets walks through how to generate one from scratch with A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → ascent on the input. The vulnerability is structural: the model's decision boundary in pixel space is closer to every input than you would expect, because high-dimensional space has more "almost-orthogonal" directions than low-dimensional intuitions allow. Mitigations include adversarial training (training on perturbed inputs at every step), certified defences (provably robust within an L-infinity ball), and ensembling. None of them fully solve the problem. The OWASP LLM Top 10 has a parallel entry for prompt-level adversarial attacks on language models, which exploits the same underlying phenomenon in a different input A form of information, like text, images, or audio.Full glossary →. See 26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications §LLM01-prompt-injection for the analogy.
Texture bias. ImageNet-trained CNNs classify by texture much more than by shape (Geirhos et al. 2019). Replace the cat's texture with elephant skin while keeping the cat's shape and a ResNet calls it an elephant. Humans don't do this; humans use shape. The implication is that the features your model has learned are not the features you think it has learned, even if its The share of guesses the model got right out of all its guesses.Full glossary → is high. The detection is one weekend's work: stylise your A separate batch of examples you check the model against while you're still tinkering, to see how it's doing.Full glossary → with neural style transfer and re-evaluate accuracy. The mitigation is harder: train on stylised data, or use shape-biased architectures (ViT happens to be more shape-biased than ResNets, by A built-in assumption that nudges a model toward certain kinds of patterns.Full glossary →). For deployed CV systems, this is a distribution-shift surface that practitioners regularly miss until users start submitting unusual inputs.
Mech-interp on vision. The Distill Circuits series (01-explorables/distill-circuits-zoom-in §motivation, distill-circuits-early-vision §gabors, distill-circuits-curve-detectors §evidence) is the most carefully-documented case of a neural network's internal mechanism being labeled by humans. They identified curve detectors, dog snout detectors, multimodal "neuron-level concepts" (distill-multimodal-neurons) where one unit fires for the concept Halle Berry regardless of whether you show it her face, her name in text, or a sketch. This is the precursor work to today's transformer mech-interp. The safety lens is that the same techniques (One piece of information about an example that the model looks at when making a guess.Full glossary → visualisation, dataset examples, ablation) let you audit what your CNN has actually learned to detect, before you deploy it. A model that has learned a spurious feature (the green hospital tag that always appears in the COVID-positive X-rays) will show up clearly in feature-visualisation when you look. The audit is a few hours of work and prevents real deployment failures. See 01-explorables/distill-circuits-zoom-in §a-rosetta-stone.
Habits to adopt when you write CNN code from now on:
- Generate at least one An input with tiny, almost invisible tweaks added on purpose to make a model give the wrong answer.Full glossary → for your trained model. A 10-line FGSM attack (the Fast Gradient Sign Method: take one step of the gradient-ascent-on-the-input idea above, , nudging every pixel by a tiny fixed amount in whichever direction raises the loss) on an MNIST classifier will show you, viscerally, that the model is brittle in directions you cannot see. This sharpens your intuition about when to deploy a CV model and when to add abstention.
- Run feature visualisation on your first conv layer. It should look like Gabor filters. If it looks like random noise, your training is broken or your model is overparameterised for the dataset.
- Test on stylised inputs. Before shipping, run your validation set through a quick style-transfer (or just heavy colour jitter) and check that accuracy degrades gracefully. If accuracy collapses, you have a texture-biased classifier and need to disclose that.
These habits compound with the multimodal-attack surface developed later in the curriculum. Image-based Sneaking extra instructions into an input that the model can't tell apart from the real task, so it follows yours too.Full glossary → against vision-language models (24-founder-blogs/willison-simonwillison-net-2025-oct-21-unseeable-prompt-injections) and adversarial-patch attacks on production CV systems both build on the brittleness this section names. another chapter §image-prompt-injection treats them as red-team practice.
FIG 12.5 · Under the hood
The library call, and the lines it hides
You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.
torchvision.resnet18 vs. from scratch
DL primitivemodel = resnet18(weights=None, num_classes=10)
# or pretrained backbone: model = resnet18(weights='IMAGENET1K_V1')class BasicBlock(nn.Module):
expansion = 1
def __init__(self, in_c, out_c, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_c, out_c, 3, stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(out_c)
self.conv2 = nn.Conv2d(out_c, out_c, 3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_c)
if stride != 1 or in_c != out_c:
self.shortcut = nn.Sequential(
nn.Conv2d(in_c, out_c, 1, stride=stride, bias=False),
nn.BatchNorm2d(out_c))
else:
self.shortcut = nn.Identity()
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
return F.relu(out + self.shortcut(x))
class ResNet(nn.Module):
def __init__(self, block, n_blocks, n_classes=10):
super().__init__()
self.in_c = 64
self.stem_conv = nn.Conv2d(3, 64, 3, stride=1, padding=1, bias=False)
self.stem_bn = nn.BatchNorm2d(64)
self.layer1 = self.make_layer(block, 64, n_blocks[0], stride=1)
self.layer2 = self.make_layer(block, 128, n_blocks[1], stride=2)
self.layer3 = self.make_layer(block, 256, n_blocks[2], stride=2)
self.layer4 = self.make_layer(block, 512, n_blocks[3], stride=2)
self.pool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512 * block.expansion, n_classes)
def make_layer(self, block, out_c, n_blocks, stride):
strides = [stride] + [1] * (n_blocks - 1)
blocks = []
for s in strides:
blocks.append(block(self.in_c, out_c, s))
self.in_c = out_c * block.expansion
return nn.Sequential(*blocks)
def resnet18(n_classes=10):
return ResNet(BasicBlock, [2, 2, 2, 2], n_classes=n_classes)from scratch: lab/solution.py: BasicBlock, ResNet, resnet18
- 1
resnet18(...) — the one-line model factorydef resnet18(): return ResNet(BasicBlock, [2,2,2,2], ...) — same [2,2,2,2] block counts per stage - 2
torchvision's internal BasicBlock (conv-bn-relu-conv-bn + skip)class BasicBlock: conv1->bn1->relu->conv2->bn2, then F.relu(out + shortcut(x)) — the identical two-conv residual unit with the additive skip - 3
downsample= module auto-inserted when stride!=1 or channels changethe `if stride != 1 or in_c != out_c:` branch building a 1x1 stride conv + BatchNorm2d as self.shortcut - 4
model.layer1..layer4 with stride doubling and widths 64,128,256,512self.layer1..layer4 built by make_layer with stride=1,2,2,2 and out_c=64,128,256,512 - 5
model.avgpool = AdaptiveAvgPool2d(1) then model.fc = Linear(512, num_classes)self.pool = nn.AdaptiveAvgPool2d((1,1)); x.flatten(1); self.fc = nn.Linear(512*expansion, n_classes) - 6
conv layers created with bias=False before each BatchNormevery nn.Conv2d uses bias=False because the following BatchNorm2d supplies the per-channel offset (beta)
What the one call hides
- Kaiming/He init on every conv plus the gamma=1/beta=0 BatchNorm init; torchvision additionally zero-inits the last BN of each block (the 'zero-init residual' trick) so blocks start as identity. The scratch class relies on plain nn.Conv2d/nn.BatchNorm2d defaults, so it skips that residual-BN zeroing.
- The ImageNet stem: torchvision uses a 7x7 stride-2 conv + 3x3 stride-2 maxpool to downsample 224x224 by 4x before layer1. The scratch model uses a 3x3 stride-1 stem and no maxpool — a deliberate CIFAR (32x32) adaptation, so the two are NOT the same overall network (11.17M vs 11.18M params, verified).
- BasicBlock vs Bottleneck dispatch: resnet18/34 use BasicBlock, resnet50+ swap in the 1x1->3x3->1x1 Bottleneck (expansion=4); one function name hides which one you get.
- All BatchNorm running-mean/running-var buffers, the momentum=0.1 stat update, and the train()/eval() switch that decides batch-stats vs running-stats.
- Pretrained ImageNet weights (weights=...) — the library can hand you a fully trained backbone; the scratch model starts random.
- Gotcha: Dropping torchvision resnet18 on CIFAR-10 32x32 trains poorly: the 7x7/maxpool stem throws away too much resolution. People copy resnet18() and wonder why it underperforms the from-scratch CIFAR variant — the stem is the reason.
- Gotcha: weights=None gives a random net; beginners assume resnet18() is pretrained (older torchvision used pretrained=True). Check the weights argument or you ship a random model.
- Gotcha: model.fc must be replaced for a non-1000-class task; forgetting num_classes silently mismatches your label space.
- Gotcha: Forgetting model.eval() at inference makes BatchNorm use the current mini-batch stats (degenerate for batch size 1), tanking accuracy.
On the job use torchvision.models.resnet18 (often with pretrained weights); the from-scratch version exists to prove resnet18() is literally these two-conv residual blocks with additive skips and 1x1 projection shortcuts, and so you can write the custom backbones (different stems/blocks/skip patterns) the library does not ship.
On the job: You rarely write the block math; you write the wrapper that loads resnet18(weights=...), swaps model.fc for your class count, optionally freezes the backbone, and (for non-ImageNet inputs like CIFAR/medical scans) edits the stem — exactly the CIFAR stem change this scratch model demonstrates.
F.cross_entropy + backward + step vs. the explicit loop
DL glueoptimizer.zero_grad()
loss = F.cross_entropy(model(x), y)
loss.backward()
optimizer.step()def train_step(model, optimizer, x, y):
optimizer.zero_grad()
logits = model(x)
loss = F.cross_entropy(logits, y)
loss.backward()
optimizer.step()
return float(loss.item())from scratch: lab/solution.py: train_step
- 1
F.cross_entropy(logits, y)the same call — log_softmax(logits) followed by negative-log-likelihood of the true class y (integer labels) - 2
loss.backward()autograd populating .grad on every parameter via reverse-mode differentiation - 3
optimizer.step()the per-parameter update rule (SGD/Adam) applied using the freshly computed grads - 4
optimizer.zero_grad()clearing the previous step's accumulated grads so they don't sum across batches
What the one call hides
- F.cross_entropy fuses log_softmax + NLL for numerical stability — it never forms an explicit softmax probability, avoiding overflow on large logits.
- It expects raw logits, not probabilities; the log-softmax is applied for you.
- Default reduction='mean' (average over the batch), plus optional class weights, label_smoothing, and ignore_index you don't see.
- Gradient accumulation semantics: grads ADD unless you zero them — the reason zero_grad() must precede backward().
- Gotcha: Passing softmax probabilities (or applying your own softmax first) double-applies the log-softmax and silently wrecks training.
- Gotcha: Targets must be int64 class indices (or, in newer torch, class probabilities); a float one-hot of the wrong dtype errors or misbehaves.
- Gotcha: Forgetting zero_grad() makes grads accumulate across steps, so your effective learning rate balloons.
- Gotcha: Calling this without model.train()/model.eval() bookkeeping leaves BatchNorm/Dropout in the wrong mode.
Prefer F.cross_entropy + autograd + a torch.optim optimizer in production; this function exists to show the universal four-line loop (zero, forward+loss, backward, step) and that the 'loss' is just stabilized softmax log-loss.
On the job: This IS the code you write at work — the per-step glue (and the epoch loop, metric logging, grad clipping, AMP, scheduler.step around it); the four-line core is exactly what you type, the library only supplies the loss and the update rule it wraps.
FIG 12.6 · Chapter notebook
Build this chapter with your own hands
A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.
What you'll build
- A 2D convolution from scratch in NumPy that reproduces the hand-computed [[19, 25], [37, 43]] and then agrees with nn.Conv2d to machine precision.
- The output-shape formula floor((W - k + 2P)/S) + 1, tested against PyTorch on a dozen layer configs.
- A learned kernel: start from random weights and recover a hand-designed vertical-edge detector by gradient descent (ground-truth recovery).
- A small CNN trained on FashionMNIST, with its parameters and FLOPs counted by hand and checked against the library.
- A deliberate failure: a model that scores 10% because you forgot one line, then the same model fixed.
- A pretrained checkpoint you reload and verify outputs against, bit-identically.
~5 min on CPU · 106 cells · 11 checked exercises · runs in Colab
FIG 12.7 · Going further
01-explorables/distill-circuits-zoom-inthe canonical "what does a CNN see, mechanistically" walkthrough. Read once you have built a CNN of your own.
01-explorables/distill-feature-visualizationthe production recipe for generating interpretable feature-vis images. The regularisation details matter.
14-arena-notebooks/chapter0-part2-cnnsthe most rigorous from-scratch CNN curriculum on the internet (Callum McDougall's ARENA). Builds ResNet-34 step by step.
13-fastbook/18_CAMclass activation maps. The fastest way to make a CNN's prediction visible to a human user, in production.
24-founder-blogs/karpathy-lecun1989Karpathy reproduces LeCun's 1989 paper on modern hardware. Charming and instructive.
16-d2l-sections/chapter_attention-mechanisms-and-transformers__vision-transformerthe d2l.ai chapter on ViT, with code. Read after this chapter, before another chapter.
29-practice-engineering/lucidrains-vitPhil Wang's reference ViT implementation. 200 lines of clean PyTorch.
01-explorables/distill-multimodal-neuronsunits in CLIP that fire for "Halle Berry" regardless of input modality. The proto-transformer interpretability work.
24-founder-blogs/karpathy-breaking-convnets50 lines of code that fool a CNN. Eye-opening.
FIG 12.8 · What this enables
Chapters you can now read, with the connecting idea written out.
1D convolutions are the WaveNet trick for sequence modeling. The arithmetic transfers directly.
ViT is the bridge. The patch embedding is one strided conv; everything after is the transformer in another chapter.
CLIP and similar models use a CNN or ViT as the image encoder. Knowing what that backbone is doing matters.
The Circuits work on vision is the proof-of-concept for the techniques that later got applied to transformer language models.
FIG 12.9 · 41 sources
- 01-explorables/distill-activation-atlas
- 01-explorables/distill-circuits-curve-detectors
- 01-explorables/distill-circuits-early-vision
- 01-explorables/distill-circuits-zoom-in
- 01-explorables/distill-feature-visualization
- 01-explorables/distill-multimodal-neurons
- 02-code-refs/amidi-cs230-cnn
- 04-stanford/cs231n-convolutional-networks
- 04-stanford/cs231n-understanding-cnn
- 08-geron-notebooks/14_deep_computer_vision_with_cnns
- 11-polo-club/cnn-explainer
- 13-fastbook/13_convolutions
- 13-fastbook/14_resnet
- 13-fastbook/18_CAM
- 14-arena-notebooks/chapter0-part2-cnns
- 16-d2l-sections/chapter_computer-vision__fcn
- 16-d2l-sections/chapter_computer-vision__rcnn
- 16-d2l-sections/chapter_computer-vision__ssd
- 16-d2l-sections/chapter_computer-vision__transposed-conv
- 16-d2l-sections/chapter_convolutional-modern__alexnet
- 16-d2l-sections/chapter_convolutional-modern__batch-norm
- 16-d2l-sections/chapter_convolutional-modern__cnn-design
- 16-d2l-sections/chapter_convolutional-modern__googlenet
- 16-d2l-sections/chapter_convolutional-modern__resnet
- 16-d2l-sections/chapter_convolutional-modern__vgg
- 16-d2l-sections/chapter_convolutional-neural-networks__conv-layer
- 16-d2l-sections/chapter_convolutional-neural-networks__lenet
- 16-d2l-sections/chapter_convolutional-neural-networks__padding-and-strides
- 16-d2l-sections/chapter_convolutional-neural-networks__pooling
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__vision-transformer
- 18-lilian-weng/2017-12-15-object-recognition-part-2
- 18-lilian-weng/2018-12-27-object-recognition-part-4
- 18-lilian-weng/2022-06-09-vlm
- 22-anthropic-recent/2021-framework-index
- 24-founder-blogs/karpathy-lecun1989
- 24-founder-blogs/karpathy-breaking-convnets
- 24-founder-blogs/karpathy-what-i-learned-from-competing-against-a-convnet-on-imagenet
- 24-founder-blogs/olah-2014-07-conv-nets-modular
- 24-founder-blogs/olah-2014-07-understanding-convolutions
- 26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications
- 29-practice-engineering/lucidrains-vit