Search Tech Journey

Find topics, journeys and posts

6-month learning plan102 / 130
back to blog
mladvanced 50m read

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.

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

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

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

How you actually look at a photograph
🌍 Real world

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.

💻 Code world

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

Everything else is engineering on top of these three
  • 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

  1. 1980
    Neocognitron · Fukushima
    Japanese researcher proposes a hierarchical, translation-invariant vision network. Nobody notices for 30 years.
  2. 1989
    LeNet-5 · Yann LeCun
    First trainable CNN. Reads handwritten ZIP codes for the US Postal Service. Ships in production.
  3. 2012
    AlexNet · Krizhevsky et al.
    Wins ImageNet by 10.8 points. Uses two GTX 580 GPUs, ReLU, and dropout. The paper that launched deep learning.
  4. 2014
    VGG-16 · Simonyan & Zisserman
    Prove that depth alone (16 layers of 3×3 convs) beats fancy 11×11 kernels. Still the default backbone in 2020.
  5. 2015
    ResNet · He et al.
    Skip connections let you train 152 layers without vanishing gradients. Wins ImageNet + COCO the same year.
  6. 2020
    Vision 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

1data
Input tensor

Shape [B, C, H, W] — batch, channels (3 for RGB), height, width.

2conv
Conv2d(3 → 64, k=3)

64 filters, each seeing a 3×3×3 patch. Output: [B, 64, H, W].

3act
ReLU

Non-linearity applied elementwise. Discards negative activations.

4pool
MaxPool2d(k=2, s=2)

Take the max over each 2×2 window. Output halves H and W. Reduces spatial detail, keeps strongest signal.

5stack
Repeat conv → pool ~3–5 times

Each block doubles channels, halves spatial dims. Features get more abstract, resolution drops.

6head
Flatten + Linear

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

[64, 3, 3, 3]
The weight tensor. 64 output filters, each is a 3-channel 3×3 kernel.
shape
1,728 weights + 64 biases
Total learnable params: 64·3·3·3 + 64 = 1,792. Compare to fully-connected: 224·224·3·64 = 9.6M.
count
Same 1,728 weights sweep entire image
Parameter sharing = the whole reason CNNs work on high-res images without exploding memory.
share
Each of 64 filters learns a different pattern
Filter 1 might learn vertical edges, filter 2 diagonal, filter 42 skin-colour blobs.
diversity

Landmark architectures — recognise on sight

AlexNet (2012)

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
VGG-16 (2014)

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
ResNet-50 (2015)

Skip connections change everything

  • 50 layers, 25M params
  • Residual blocks: y = F(x) + x
  • Enabled 152-layer training
  • The default backbone for a decade
Inception v3 (2015)

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.


Common misconception
✗ What most people think

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

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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

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.

  1. 1
    Receptive 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
  2. 2
    But 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
  3. 3
    The 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
  4. 4
    Compute 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
  5. 5
    Therefore 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

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.

Mental modelA sliding template, reused everywhere, stacked into a hierarchy

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.
🔔 Fires when you see

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.

The tradeoff

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?

Train from scratch
+ you gain full architectural freedom — you can match the design to your input size, channel count, and latency budget exactly, which matters for non-RGB data like spectrograms, medical volumes, or satellite bands where pretrained weights do not transfer cleanly; and there are no licence or provenance questions about the weights
− you pay needs data volume that a few thousand examples cannot supply, so it will almost certainly underperform any transfer approach; training cost is high and tuning is from zero, with no known-good recipe to start from
pick when your input differs fundamentally from natural images, or you have very large volumes of domain data — rarely the case at a few thousand examples
Fine-tune a pretrained backbone
+ you gain reuses low-level features (edges, textures, colour gradients) that are genuinely universal across visual domains, so you only need to learn the domain-specific upper layers; typically the best accuracy per labelled example by a wide margin, and it converges in a fraction of the epochs
− you pay you inherit the backbone's input assumptions — resolution, normalisation statistics, channel count — and mismatching them silently degrades results; risk of catastrophic forgetting if the learning rate is too high on early layers; and the model is as large as whatever you started from, which may exceed your latency budget
pick when a few hundred to a few tens of thousands of labelled examples in a visual domain — the default, and the right answer here
Frozen backbone as a feature extractor
+ you gain extremely cheap — run the backbone once, cache the embeddings, then train a small classifier or even a gradient booster on top in seconds; you can iterate on the head dozens of times without touching the GPU again, and it works with remarkably few labels
− you pay the features are fixed, so if your domain differs from the pretraining distribution the representation may simply not encode what you need, and no amount of head-tuning recovers it; accuracy ceiling is lower than fine-tuning
pick when very few labels (hundreds), a domain close to natural images, or when you need a strong baseline within an hour
What a senior engineer actually does

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

conv1a = Conv2d(3, 64, k=3, padding=1)
Input 3 channels (RGB), 64 output filters, 3×3 kernel, padding=1 keeps spatial size. 3·64·3·3 + 64 = 1,792 params.
conv
self.pool = MaxPool2d(2, 2)
Halves H and W by taking the max over each 2×2 window. Zero learnable params. After 3 pools: 32 → 16 → 8 → 4.
pool
x.flatten(1)
Reshape [B, 256, 4, 4] to [B, 4096]. Keeps batch dim, flattens the rest. Ready for a Linear layer.
reshape
RandomCrop(32, padding=4)
Data augmentation. Randomly crops a 32×32 patch from a 40×40 padded version — teaches the model translation invariance beyond what convs give.
augment
Normalize((0.49, 0.48, 0.45), …)
Per-channel mean/std for CIFAR-10 (computed once from the training set). Subtract mean, divide by std — helps training converge faster.
norm
Adam(lr=1e-3)
Adaptive optimiser. Almost always the right first choice for CNNs. SGD+momentum gives ~1% more accuracy on CIFAR but needs a schedule.
opt
loss.backward()
Autograd computes gradients for every parameter, including through the shared conv weights. Backprop through convolution = another convolution.
grad
Try itTurn the plain CNN into a mini-ResNet by adding one skip connection

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.

💡 Hint · In `forward`, save `residual = x` before block 2, then after `conv2b + pool`, do `x = x + F.avg_pool2d(residual_projected_to_128_channels, 2)`. You'll need a 1×1 conv to project 64 → 128 channels. Compare test accuracy after 5 epochs.

(d) Production reality · 15 min

War story Tesla Autopilot· 20211M+ vehicles
🔥 What broke

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.

🧯 The fix
They rebuilt the stack as a single "HydraNet" — a shared ResNet backbone processes all 8 cameras' features into a unified bird's-eye-view before any task-specific heads. Recall on partially-occluded objects jumped substantially.
🎓 Lesson to steal
Where you fuse information matters more than how deep your CNN is. Late fusion = each branch learns in isolation. Early fusion = shared representation, better cross-view reasoning.
Post-mortem
War story Instagram · Facebook AI· 20193.5B images
🔥 What broke
Instagram wanted to build a strong image classifier without paying for ImageNet-scale labelled data. Their researchers trained a ResNeXt on 3.5B public Instagram photos, using hashtags as noisy labels. First runs collapsed — the model learned to predict a handful of popular hashtags (#love, #instagood) and ignore everything else.
🧯 The fix
They filtered hashtags to a 17K vocabulary of visual concepts (removed generic hashtags), reweighted the loss to compensate for the long tail, and used group-normalisation instead of batch-norm (batch stats were unstable at their scale). Model then set a new ImageNet SOTA when fine-tuned.
🎓 Lesson to steal
Noisy weak labels at web scale beat clean labels at ImageNet scale — but only if you carefully engineer the label vocabulary and loss to survive the noise.
Post-mortem
War story Google Health · diabetic retinopathy· 2018900+ clinics
🔥 What broke
Google's Inception-v3-based screening model hit 90%+ accuracy in the lab. In Thai clinics it choked: nurses were sending photos in bad light, phones with dust on the lens, and the model would flag every marginal image as "ungradable," dumping thousands of manual review cases on already-overworked staff.
🧯 The fix
Two changes: (1) an ensemble with a much smaller "image quality" CNN that ran first and only escalated high-quality images to the classifier, and (2) UX changes — the app coached nurses to retake photos in real time when quality was low.
🎓 Lesson to steal
A CNN's accuracy on your test set means nothing if your data distribution shifts in deployment. Add an out-of-distribution detector before the classifier, always.
Post-mortem

Where this shows up in the rest of the plan

CNNs are the foundation of every image, audio, and multimodal model that follows
S103 · RNNs & LSTMs
The sequence-model cousin of CNNs. Both use weight sharing — CNNs across space, RNNs across time.
S105 · Transfer Learning
You'll fine-tune the exact CNNs (ResNet, EfficientNet) you learned to recognise here.
S111 · Full Transformer
Vision Transformers replaced convs — but only for datasets \>100M images. Below that CNNs still win.
S127 · Multimodal (CLIP)
The image encoder in CLIP is a ResNet or ViT. You'll wire it to a text encoder for zero-shot classification.
S068 · Model Serving
Deploying a CNN is where you learn about ONNX, TorchScript, and TensorRT for 10× inference speedups.
S117 · Diffusion Models
The U-Net inside every image diffusion model is a CNN with skip connections. Everything you learned here recurs.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

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

  1. Why does parameter sharing let a CNN scale to megapixel images when an MLP can't?
  2. What is a receptive field, and how does depth increase it?
  3. 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.