S102 · CNNs — Convolution, Pooling, ImageNet Architectures
How a 3×3 sliding window unlocked vision — from LeNet's zip codes to ResNet's 152 layers. Convolution, pooling, receptive fields, and the architectures every ML engineer must recognise on sight.
🎯 Explain, build, and debug a CNN — including why convolutions beat MLPs on images and how ResNet's skip connections tamed 152-layer training.
Why this session exists
Between 2012 and 2015, three CNN papers — AlexNet, VGG, and ResNet — dropped ImageNet top-5 error from 26% to 3.5% and effectively killed hand-crafted computer vision as a research field. Every self-driving car, medical imaging pipeline, and phone camera scene detector today runs the same primitives: convolution, pooling, and skip connections. Understanding them from first principles is table-stakes for anyone doing modern ML.
- Explain why a 3×3 conv shares weights across space and why that beats a fully-connected layer on images.
- Compute the output shape of any conv/pool layer given stride, padding, and kernel size — no lookup.
- Recognise AlexNet, VGG, ResNet, and Inception on sight from a diagram or code snippet.
- Build a working CNN in PyTorch for CIFAR-10 and explain each `nn.Conv2d` argument.
- Describe what a ResNet skip connection does mathematically and why it made 100+ layer training possible.
Prerequisites
- S100 · PyTorch Fundamentals — you need
nn.Module,nn.Conv2d, and autograd. - S099 · Backpropagation — a CNN is just backprop through shared weights.
- S096 · Multilayer Perceptrons — the thing CNNs replaced for images.
(a) Intuition · 5 min
Look at the person sitting across the room. Your eyes don't process 100 million retinal pixels in parallel with equal attention. They saccade in patches — first noticing edges (the outline of a face), then curves (an eye socket), then compositions (both eyes plus a nose = a face), then meaning ("that's Dinesh").
Crucially, the "edge detector" in your visual cortex is the same neurons whether the edge is in the top-left or bottom-right of your visual field. You don't grow a new edge detector for every location.
A CNN implements exactly this. A tiny 3×3 filter slides across every position in the image, computing a dot product at each location. The same nine weights are re-used everywhere — this is parameter sharing.
Stack many such filters, add pooling to downsample, and you get a hierarchy: layer 1 detects edges, layer 3 detects textures, layer 6 detects object parts, layer 12 detects "cat vs dog." No hand-crafted features, all learned by backprop.
The three ideas that make CNNs work
- Parameter sharing — one 3×3 kernel = 9 weights (or 27 for RGB) that scan the entire image. A fully-connected layer on 224×224×3 would need 150,528 weights per output neuron. 1000× fewer parameters, same expressive power for spatial patterns.
- Local connectivity — each neuron only sees a small patch of the previous layer, not the whole image. Reflects the fact that meaningful visual patterns are local (an eye is 20×20 pixels, not 224×224).
- Translation invariance — a cat is a cat whether it's in the top-left or bottom-right. Sliding the same filter everywhere means the network learns 'cat-ness' as a pattern, not a position.
A timeline of the vision revolution
- 1980Neocognitron · FukushimaJapanese researcher proposes a hierarchical, translation-invariant vision network. Nobody notices for 30 years.
- 1989LeNet-5 · Yann LeCunFirst trainable CNN. Reads handwritten ZIP codes for the US Postal Service. Ships in production.
- 2012AlexNet · Krizhevsky et al.Wins ImageNet by 10.8 points. Uses two GTX 580 GPUs, ReLU, and dropout. The paper that launched deep learning.
- 2014VGG-16 · Simonyan & ZissermanProve that depth alone (16 layers of 3×3 convs) beats fancy 11×11 kernels. Still the default backbone in 2020.
- 2015ResNet · He et al.Skip connections let you train 152 layers without vanishing gradients. Wins ImageNet + COCO the same year.
- 2020Vision Transformer · Dosovitskiy et al.Attention replaces convolution — but only after 300M pretraining images. CNNs remain dominant \<100M images.
(b) Visual walkthrough · 15 min
The convolution operation
The kernel slides across the input; at each of the 9 valid positions on a 5×5 input, you compute the element-wise multiply + sum. Output is 3×3 because (5 - 3) / 1 + 1 = 3 (kernel size 3, stride 1, no padding).
The output shape formula — memorise this
The full CNN pipeline
Shape [B, C, H, W] — batch, channels (3 for RGB), height, width.
64 filters, each seeing a 3×3×3 patch. Output: [B, 64, H, W].
Non-linearity applied elementwise. Discards negative activations.
Take the max over each 2×2 window. Output halves H and W. Reduces spatial detail, keeps strongest signal.
Each block doubles channels, halves spatial dims. Features get more abstract, resolution drops.
Reshape [B, C, h, w] → [B, C·h·w], then a fully-connected head produces class logits.
Anatomy of a conv layer's weight tensor
Conv2d(in_channels=3, out_channels=64, kernel_size=3) weight shape
Landmark architectures — recognise on sight
The one that started it all
- 8 layers, 60M params
- 11×11 first conv (too big in hindsight)
- ReLU + dropout debuted
- Split across two GTX 580 GPUs due to 3GB VRAM
Deeper with tiny kernels
- 16 layers, 138M params
- Only 3×3 convs — proved small kernels + depth wins
- Uniform, boring, effective
- Still used as feature extractor in 2024
Skip connections change everything
- 50 layers, 25M params
- Residual blocks: y = F(x) + x
- Enabled 152-layer training
- The default backbone for a decade
Parallel filter sizes
- 48 layers, 24M params
- 1×1, 3×3, 5×5 convs in parallel per block
- 'Let the network pick the scale'
- Complex to hand-code, still used in Google prod
Why ResNet's skip connection was revolutionary
Before ResNet, adding more layers hurt accuracy past ~20 layers because gradients vanished through the depth. The trick: instead of learning y = F(x), learn y = F(x) + x. If the layer has nothing to add, F can just learn zero — the identity is preserved. Gradients flow through the + x skip as a highway. Suddenly 152 layers train fine.
"CNNs work because convolution detects edges and shapes — the filters are like the edge detectors from image processing, just learned instead of hand-designed. That's why they beat MLPs on images."
Learned filters are a consequence, not the cause. What makes CNNs work is two structural priors baked into the architecture: locality (a pixel's meaning depends mostly on its neighbours) and weight sharing (a feature worth detecting in one location is worth detecting everywhere). Those priors slash the parameter count by orders of magnitude and encode translation equivariance for free. An MLP could learn edge detectors — it just has no reason to, and not remotely enough data to discover that structure on its own.
The myth is sticky because the first-layer filter visualisations are genuinely striking — they really do look like Gabor filters and oriented edge detectors, and that image is in every tutorial. It confirms a satisfying story. But the same visualisation appears for almost any architecture trained on natural images, because edges are what natural images contain. The property that actually distinguishes a CNN is that the identical filter is applied at every spatial position, and that is invisible in a picture of the filter.
Count parameters for the same input and the same output channel count. The gap is the entire argument:
# 224x224x3 input, 64 output feature maps
# fully connected to a 224x224x64 output:
fc = (224*224*3) * (224*224*64) # ~1.5e11 weights
# 3x3 conv, 3 in-channels, 64 out-channels:
conv = 3*3*3*64 + 64 # 1,792 weights
print(fc / conv) # ~8 orders of magnitude
# and the conv version generalises across position for free:
# shift the input by one pixel and the FC layer sees a
# completely different input vector. the conv layer does not.Why does stacking small 3×3 convolutions beat using one large 7×7 convolution, when a single 7×7 covers the same receptive field in one operation? It looks like more layers for the same coverage. It is strictly better on three axes.
- 1Receptive field grows additively with stacked layers. Three consecutive 3×3 convolutions give each output unit a 7×7 view of the input, identical to one 7×7 layer.forced by · each layer adds (k−1) to the receptive field, so 3 layers of 3×3 add 2+2+2 to a starting width of 1
- 2But the parameter counts differ sharply. For C channels in and out, one 7×7 costs 49C² weights; three 3×3 layers cost 3 × 9C² = 27C² — roughly 45% fewer for identical coverage.forced by · parameters scale with k² per layer but only linearly with the number of layers
- 3The stacked version also inserts a non-linearity between each convolution, so it computes a strictly richer family of functions. The single 7×7 is one linear map followed by one activation; three layers give three of each.forced by · composition with non-linearities in between cannot be collapsed into a single linear operation
- 4Compute follows parameters, since each output position performs k² × C_in multiply-accumulates per output channel. So the small-kernel stack is also cheaper in FLOPs for the same receptive field.forced by · convolution cost is proportional to kernel area times channel counts times spatial positions
- 5Therefore small stacked kernels dominate large ones on parameters, expressiveness, and compute simultaneously — which is why architectures converged on 3×3 as the standard building block.forced by · there is no axis on which the large kernel wins, given equal receptive field
Therefore depth with small kernels is not a stylistic trend; it is the efficient way to purchase receptive field.
And note what this predicts: receptive field must grow slowly — only linearly with depth for plain stacked convolutions. To see the whole image, a network needs either many layers, or a mechanism that grows it faster. That is exactly why stride and pooling exist (they multiply the effective field by downsampling) and why dilated convolutions were invented for dense prediction tasks that cannot afford to downsample. Both are direct answers to a limitation the derivation says must exist.
One filter is a small template that slides across the image, and at every position it reports how strongly the local patch matches. The output is a map of where that pattern occurs. Because the same weights are applied at every position, a pattern learned from one part of one image is immediately available everywhere.
Stack these and a hierarchy emerges automatically: layer one sees only a 3×3 patch and can express nothing more than edges; layer two sees combinations of layer-one outputs, so it can express corners and textures; deeper layers compose those into parts and objects. Depth is not extra capacity — it is the mechanism by which the receptive field and the abstraction level grow together.
- Output size = (input − kernel + 2·padding)/stride + 1. Padding "same" with stride 1 preserves spatial dimensions; stride 2 halves them.
- Parameters per conv layer = k × k × C_in × C_out + C_out. Independent of input resolution — which is exactly why a CNN accepts variable input sizes and an MLP cannot.
- Convolution is translation equivariant (shift input, shift output), not invariant. Pooling and global average pooling are what convert equivariance into invariance.
- The standard block is conv → normalisation → activation, and residual connections are what allow depth beyond a few dozen layers by giving gradients a path that skips the transformations.
Fire this the moment you see: an MLP applied to raw pixels · a spatial dimension mismatch after several conv layers · a very deep plain CNN with no residual connections · a segmentation model that downsamples aggressively and loses fine detail · a model that fails on shifted or differently-cropped inputs · a huge first-layer kernel.
You need an image model for a domain-specific task with a few thousand labelled examples. Train a CNN from scratch, fine-tune a pretrained backbone, or use a frozen backbone as a feature extractor?
Fine-tune a pretrained backbone. At a few thousand examples the low-level features you would spend all your data learning are exactly the ones already available for free, and training from scratch is spending your scarce labels on a solved problem. Start by freezing the early layers and training the head, then unfreeze progressively with a lower learning rate on earlier layers.
Establish the frozen-feature baseline first, though — it takes minutes and tells you immediately whether the pretrained representation is even relevant to your domain. If a linear probe on cached embeddings already performs well, your problem is easier than assumed. If it performs at chance, that is strong evidence your domain is far from the pretraining distribution, and it is worth knowing that before spending a week fine-tuning something that was never going to transfer.
(c) Hands-on · 25 min
Build a real CNN for CIFAR-10 (32×32 colour images, 10 classes). This trains to ~75% accuracy in 5 minutes on CPU, ~90% on GPU with more epochs. No cheating — use only torch and torchvision.
# cifar_cnn.py — train a CNN on CIFAR-10, print accuracy per epoch.
# Run: uv run cifar_cnn.py
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
BATCH = 128
EPOCHS = 5
LR = 1e-3
class SmallCNN(nn.Module):
"""A 4-block CNN. 3 → 64 → 128 → 256 → 512 channels, pooling halves spatial dims each block."""
def __init__(self, num_classes: int = 10):
super().__init__()
# Block 1: 32×32 → 16×16
self.conv1a = nn.Conv2d(3, 64, kernel_size=3, padding=1)
self.conv1b = nn.Conv2d(64, 64, kernel_size=3, padding=1)
# Block 2: 16×16 → 8×8
self.conv2a = nn.Conv2d(64, 128, kernel_size=3, padding=1)
self.conv2b = nn.Conv2d(128, 128, kernel_size=3, padding=1)
# Block 3: 8×8 → 4×4
self.conv3a = nn.Conv2d(128, 256, kernel_size=3, padding=1)
self.conv3b = nn.Conv2d(256, 256, kernel_size=3, padding=1)
# Head
self.pool = nn.MaxPool2d(2, 2)
self.dropout = nn.Dropout(0.3)
self.fc1 = nn.Linear(256 * 4 * 4, 512)
self.fc2 = nn.Linear(512, num_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# Block 1
x = F.relu(self.conv1a(x))
x = F.relu(self.conv1b(x))
x = self.pool(x) # 32 → 16
# Block 2
x = F.relu(self.conv2a(x))
x = F.relu(self.conv2b(x))
x = self.pool(x) # 16 → 8
# Block 3
x = F.relu(self.conv3a(x))
x = F.relu(self.conv3b(x))
x = self.pool(x) # 8 → 4
# Head
x = x.flatten(1) # [B, 256*4*4]
x = self.dropout(F.relu(self.fc1(x)))
return self.fc2(x)
def get_loaders() -> tuple[DataLoader, DataLoader]:
tfm_train = transforms.Compose([
transforms.RandomCrop(32, padding=4),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)),
])
tfm_test = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)),
])
train = datasets.CIFAR10("./data", train=True, download=True, transform=tfm_train)
test = datasets.CIFAR10("./data", train=False, download=True, transform=tfm_test)
return (
DataLoader(train, batch_size=BATCH, shuffle=True, num_workers=2),
DataLoader(test, batch_size=BATCH, shuffle=False, num_workers=2),
)
def evaluate(model: nn.Module, loader: DataLoader) -> float:
model.eval()
correct = total = 0
with torch.no_grad():
for x, y in loader:
x, y = x.to(DEVICE), y.to(DEVICE)
pred = model(x).argmax(dim=1)
correct += (pred == y).sum().item()
total += y.size(0)
return 100.0 * correct / total
def main() -> None:
train_loader, test_loader = get_loaders()
model = SmallCNN().to(DEVICE)
opt = optim.Adam(model.parameters(), lr=LR)
loss_fn = nn.CrossEntropyLoss()
params = sum(p.numel() for p in model.parameters())
print(f"device={DEVICE} params={params:,}")
for epoch in range(1, EPOCHS + 1):
model.train()
running = 0.0
for step, (x, y) in enumerate(train_loader, 1):
x, y = x.to(DEVICE), y.to(DEVICE)
opt.zero_grad()
loss = loss_fn(model(x), y)
loss.backward()
opt.step()
running += loss.item()
acc = evaluate(model, test_loader)
print(f"epoch {epoch} train_loss={running/step:.3f} test_acc={acc:.2f}%")
if __name__ == "__main__":
main()Anatomy of the script
What the interesting lines do
Add a nn.Conv2d(64, 128, kernel_size=1) layer, use it to project the residual before adding, and observe whether accuracy improves or degrades. On CIFAR-10 with only 3 blocks the gain is small (~1%), but the machinery is exactly what powers ResNet-152.
(d) Production reality · 15 min
Andrej Karpathy revealed in a CVPR talk that Tesla's early Autopilot vision stack used per-camera CNN backbones with separate heads. The problem: each camera's CNN saw its own patch of the world, and the fusion happened only at the very end — so the network never learned that "a truck to the left" and "a truck partially visible from the front camera" were the same truck.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- Why does parameter sharing let a CNN scale to megapixel images when an MLP can't?
- What is a receptive field, and how does depth increase it?
- What does a ResNet skip connection do, and why did it change deep learning?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.