In September 2012, a team from the University of Toronto
submitted an entry to the ImageNet Large Scale Visual Recognition Challenge.
The competition had run since 2010. The best error rate to that point: around 25%.
AlexNet scored 15.3%. The second-place team: 26.2%.
A gap that size doesn't happen in a mature competition.
The judges assumed a mistake had been made. It hadn't.
A paradigm had shifted — and everything that followed,
from self-driving cars to medical AI, traces back to that moment.
01 — The Problem with Feeding Images to a Dense Network
Before building a CNN, it's worth understanding exactly why the obvious approach — feeding raw pixel values into the dense neural network from Day 15 — fails so badly on images. The failure has two distinct causes, and the CNN solves both simultaneously.
Problem 1: The parameter explosion. A colour image at 224×224 resolution contains 224 × 224 × 3 = 150,528 numbers. A dense hidden layer with 1,000 neurons needs one weight per input-output pair: 150,528 × 1,000 = 150.5 million parameters — for a single layer. That's not just slow to train. It's statistically hopeless. With millions of parameters, you need tens of millions of training images just to avoid catastrophic overfitting, before even adding more layers. The entire ImageNet training set has about 1.2 million images. A single dense layer already needs more parameters than can be meaningfully trained on it.
Problem 2: Spatial blindness. In a dense layer, every input neuron connects to every output neuron with a unique, independent weight. There is no concept of "pixel (12, 15) is adjacent to pixel (12, 16)." The network has to learn spatial relationships from scratch, from weights — and it has to re-learn them independently for every possible position. An eye in the top-left corner and an eye in the bottom-right corner of an image are structurally identical, but a dense network treats them as completely different phenomena requiring completely different weights. This is wildly inefficient for any data where spatial structure matters.
import torch
import torch.nn as nn
# ── Demonstrate the parameter gap ────────────────────────────────────────
# Image: 224×224 colour (standard ImageNet input)
img_pixels = 224 * 224 * 3 # = 150,528
hidden_size = 1000
# Dense MLP — first hidden layer only
dense_layer = nn.Linear(img_pixels, hidden_size)
dense_params = sum(p.numel() for p in dense_layer.parameters())
# CNN — first conv layer: 3 input channels, 64 filters, 3×3 kernel
# (same as AlexNet's first layer configuration, but 3×3 not 11×11)
conv_layer = nn.Conv2d(3, 64, kernel_size=3, padding=1)
conv_params = sum(p.numel() for p in conv_layer.parameters())
print(f"Image size: {img_pixels:>10,} numbers (224×224×3)")
print(f"Dense layer params: {dense_params:>10,} (150,528 × 1,000 + bias)")
print(f"Conv layer params: {conv_params:>10,} (3×3×3×64 + 64 bias)")
print(f"Ratio: {dense_params/conv_params:>10.0f}× more params in dense")
print()
# Full network comparison — 3-layer dense MLP vs 3-block CNN
dense_mlp = nn.Sequential(
nn.Flatten(),
nn.Linear(150528, 1000), nn.ReLU(),
nn.Linear(1000, 512), nn.ReLU(),
nn.Linear(512, 10),
)
simple_cnn = nn.Sequential(
nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 224→112
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 112→56
nn.Conv2d(64, 128,3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool2d(1),
nn.Flatten(),
nn.Linear(128, 10),
)
mlp_total = sum(p.numel() for p in dense_mlp.parameters())
cnn_total = sum(p.numel() for p in simple_cnn.parameters())
print(f"3-layer dense MLP: {mlp_total:>10,} parameters")
print(f"3-block CNN: {cnn_total:>10,} parameters")
print(f"CNN is {mlp_total/cnn_total:.0f}× smaller, yet better at images")
The first dense layer alone has 150 million parameters — more than many production CNN models in their entirety. The 3-block CNN has 146,474 parameters and achieves far better accuracy on image tasks. The ratio is over 1,000:1. This isn't a minor efficiency improvement; it's a fundamental difference in how well the architecture matches the structure of the data.
The conv layer's 1,792 parameters breaks down as: 3×3 kernel × 3 input channels × 64 filters = 1,728 weight parameters, plus 64 bias terms = 1,792 total. Those 1,728 weights scan the entire 224×224 image and produce 64 feature maps — 64 different pattern detectors applied everywhere, simultaneously. A dense layer needs 150 million parameters to do less.
02 — The Convolution Operation, Step by Step
A convolution filter is a small grid of learned numbers — typically 3×3. You place it at the top-left corner of the image, multiply each filter value by the corresponding pixel value underneath it, sum all 9 products, and write that single number to an output grid. Then slide the filter one pixel right and repeat. When you reach the end of the row, drop one row and start again from the left. The complete output is called a feature map — one value per position, representing how strongly the filter's pattern appeared at each location.
import numpy as np
# ── Manual 2D convolution — one filter, one pass ──────────────────────────
# Simulated 5×5 grayscale image patch
image = np.array([
[10, 10, 0, 0, 0],
[10, 10, 0, 0, 0],
[10, 10, 0, 0, 0],
[10, 10, 0, 0, 0],
[10, 10, 0, 0, 0],
], dtype=float) # Bright left half, dark right half — vertical edge at column 2
# Sobel vertical edge detector (this is what CNNs often learn as first filters)
filter_vertical = np.array([
[-1, 0, 1],
[-1, 0, 1],
[-1, 0, 1],
])
# Manual convolution (no padding, stride=1) → output is (5-3+1)×(5-3+1) = 3×3
H, W = image.shape
fH, fW = filter_vertical.shape
out_H, out_W = H - fH + 1, W - fW + 1
feature_map = np.zeros((out_H, out_W))
for i in range(out_H):
for j in range(out_W):
patch = image[i:i+fH, j:j+fW] # 3×3 patch of the image
feature_map[i, j] = (patch * filter_vertical).sum() # element-wise multiply + sum
print("Image (bright left, dark right):")
print(image.astype(int))
print("\nVertical-edge filter:")
print(filter_vertical)
print("\nFeature map (response to vertical edge):")
print(feature_map.astype(int))
print("\nInterpretation:")
print(" Column 1 response: large negative (left side of bright region)")
print(" Column 2 response: zero (inside bright region, no edge)")
print(" Column 3 response: large neg. (right side — transition zone)")
print("\nAfter abs(ReLU): the filter fires strongly at the vertical edge position")
# Show one computation step manually
print("\n── Manual computation at position (0,1) ──")
patch = image[0:3, 1:4]
print(f"Patch:\n{patch.astype(int)}")
print(f"Filter:\n{filter_vertical}")
products = patch * filter_vertical
print(f"Element-wise products:\n{products.astype(int)}")
print(f"Sum = {products.sum():.0f} → feature_map[0,1]")
The image has bright pixels (value 10) on the left two columns and dark pixels (value 0) on the right three. This is a clean vertical edge — exactly the kind of boundary that appears in real images at object borders.
The filter [-1, 0, 1] in each row is designed to fire when the left side of a 3×3 patch is darker and the right side is brighter (positive response) or vice versa (negative response). At position (0,1), the patch has brightness 10 on the left column and 0 on the right — so left × (−1) + right × 1 = −10 + 0 = −10 per row, summed across 3 rows = −30. Strong negative response. After ReLU(max(0, −30)) this becomes 0, but after applying the filter in the opposite orientation or using abs(), the strong response at the edge position is preserved.
In a real CNN, the filter weights are not hand-crafted. They start random and are learned by backpropagation. But CNNs trained on natural images typically learn something very close to this Sobel-like filter in their first layer — because edges are genuinely the most discriminative low-level feature in natural images. The network discovers this on its own.
03 — The CNN Forward Pass: Layer by Layer
A single convolution produces one feature map. A layer has many filters — say 64 — each looking for a different pattern. Apply all 64 to the same input and you get 64 feature maps stacked together: a 3D volume of shape (64, H, W). This volume is the input to the next layer. Each successive layer detects patterns in the previous layer's patterns — building a hierarchy from simple to complex.
max(0, x) element-wise. Without activation, stacking convolutions is mathematically equivalent to one convolution (linear × linear = linear). ReLU breaks linearity at every layer, enabling the hierarchical non-linear representations that make CNNs powerful. It also solves the vanishing gradient problem in convolutions (gradient = 1 for positive activations, 0 for negative — same mechanism as in Day 15's MLP).
04 — Receptive Fields: What Each Neuron Actually Sees
A neuron in the first conv layer with a 3×3 filter sees a 3×3 patch of the input image. Call this its receptive field — the region of the original input that influences its output. A neuron in the second conv layer sees a 3×3 patch of the first layer's output — but each of those first-layer neurons itself saw a 3×3 patch of the original input. So the second-layer neuron's receptive field in the original image is 5×5. With pooling (which downsamples by 2×), receptive fields grow even faster.
import numpy as np
# Receptive field growth with depth — 3×3 conv, no pooling
print("Receptive field growth through conv layers (3×3 kernel, stride=1):")
print(f"{'Layer':>6} {'Receptive Field':>17} {'What it 'sees' in the image':>35}")
print("-" * 65)
rf = 1
descriptions = [
"raw pixels",
"micro-textures, single edges",
"edges, gradients, corners",
"simple shapes, stripes",
"curves, compound edges",
"textures, patterns",
"object parts (eye, wheel)",
"full objects, faces",
]
for layer in range(8):
rf = rf + 2 if layer > 0 else 1
print(f"{layer:>6} {rf:>8}×{rf:<8} {descriptions[layer]:>35}")
print("\nWith 2×2 max-pooling after every 2 conv layers:")
print(f"{'Layer':>6} {'RF (orig. image)':>17} {'Notes':>35}")
print("-" * 65)
rf_pool = 1; scale = 1
pool_notes = ["input","3×3 patch","pooling → RF doubles","11×11 effective","pooling again","27×27 — half the face"]
rf_vals = [1,3,5,10,14,28]
for i, (rv, note) in enumerate(zip(rf_vals, pool_notes)):
print(f"{i:>6} {rv:>8}×{rv:<8} {note:>35}")
A neuron in layer 1 responds to a 3×3 patch — individual edges and micro-textures. A neuron in layer 6 responds to a 13×13 patch — patterns large enough to capture an eye or a wheel. A neuron at the very end of a deep network like ResNet-50 has a receptive field that covers the entire 224×224 image. It can fire based on the overall configuration of the entire scene, not just a local patch.
This is what "hierarchical feature learning" means concretely: not an abstract principle, but a geometric fact about how receptive fields grow with depth. Deeper layers see more context, therefore they can represent more abstract concepts. The architecture forces this — no hand-engineering required. Each layer builds on what the previous layers detected.
05 — Batch Normalisation: The Training Stabiliser
Batch Normalisation (BatchNorm), introduced by Ioffe and Szegedy at Google in 2015, is one of the most impactful practical contributions in deep learning history. Before it, training networks deeper than about 20 layers required extremely careful weight initialisation and small learning rates. After it, 50-layer or 100-layer networks trained stably as a matter of routine. Understanding what it does and why it helps is essential.
For a mini-batch of activations going through a layer, BatchNorm:
1. Computes the mean μ and variance σ² of the activations across the batch.
2. Normalises: x̂ = (x − μ) / √(σ² + ε) — zero mean, unit variance.
3. Scales and shifts with two learned parameters: y = γ·x̂ + β
The γ (scale) and β (shift) are learned during training — they let the network decide how normalised to keep the activations. If the network discovers that the optimal representation happens to have a certain mean and variance, it can learn that through γ and β, while still benefiting from the normalisation's stabilising effect during backpropagation.
At inference time, the batch mean and variance are replaced by running statistics accumulated during training — so the model behaves consistently regardless of batch size. This is why you must call model.eval() before inference in PyTorch.
import numpy as np
# Simulate what BatchNorm does to activations
np.random.seed(42)
# Activations from a conv layer — wildly different scales across channels
# 4 examples, 4 channels, each channel at very different scale
activations = np.array([
[ 100, 0.01, -50, 200],
[ 150, 0.02, -30, 180],
[ 80, 0.005,-70, 220],
[ 120, 0.015,-45, 195],
])
# Columns: 4 channels. Rows: 4 examples in the batch.
# BatchNorm: normalise each channel across the batch
mu = activations.mean(axis=0) # mean per channel
var = activations.var(axis=0) # variance per channel
eps = 1e-8
normed = (activations - mu) / np.sqrt(var + eps)
print("Before BatchNorm — activations (4 examples × 4 channels):")
print(activations)
print(f"\nChannel means: {mu}")
print(f"Channel std devs: {np.sqrt(var).round(4)}")
print("\nAfter BatchNorm — normalised activations:")
print(normed.round(3))
print(f"\nPost-norm means: {normed.mean(axis=0).round(6)} ← all ≈ 0")
print(f"Post-norm std devs: {normed.std(axis=0).round(4)} ← all ≈ 1")
print("\nGradient magnitude stabilised — all channels now comparable scale.")
print("Channel 2 (0.01–0.02 range) and Channel 1 (80–150 range) now equal footing.")
Without BatchNorm, channel 1 (range 0.01–0.02) and channel 2 (range 80–150) produce gradients of vastly different magnitudes. The learning rate that helps channel 2 update properly causes channel 1 to explode — and vice versa. The deeper the network, the worse this gets as each layer compounds the scaling mismatch. Training requires tiny learning rates and careful initialisation to avoid numerical chaos.
With BatchNorm, every channel at every layer is renormalised to mean ≈ 0, std ≈ 1. Gradients propagate at consistent magnitudes regardless of what the upstream layers are doing. Learning rates can be 10–100× larger. Training converges 5–10× faster. Networks 10–100 layers deep that previously diverged now train stably. This is why BatchNorm is in essentially every modern deep CNN and Transformer (where it becomes LayerNorm, a close relative).
06 — A Complete CNN: Training on Real Data
Here is a production-structured CNN trained on the CIFAR-10 dataset — 60,000 colour images across 10 classes. Everything from the previous sections comes together: convolutions, BatchNorm, ReLU, pooling, dropout, and a classifier head.
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# ── Data augmentation + normalisation ────────────────────────────────────
transform_train = transforms.Compose([
transforms.RandomHorizontalFlip(), # augment: flip left/right
transforms.RandomCrop(32, padding=4), # augment: crop with padding
transforms.ToTensor(),
transforms.Normalize((0.4914,0.4822,0.4465), # CIFAR-10 mean per channel
(0.2470,0.2435,0.2616)) # CIFAR-10 std per channel
])
transform_test = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4914,0.4822,0.4465),
(0.2470,0.2435,0.2616))
])
train_ds = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train)
test_ds = datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_test)
train_loader = DataLoader(train_ds, batch_size=128, shuffle=True, num_workers=2)
test_loader = DataLoader(test_ds, batch_size=256, shuffle=False, num_workers=2)
# ── Model: Conv → BN → ReLU → Pool (3 blocks) ────────────────────────────
class CNN(nn.Module):
def __init__(self):
super().__init__()
def conv_block(in_ch, out_ch):
return nn.Sequential(
nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_ch), # bias=False in Conv because BN has its own β
nn.ReLU(inplace=True),
nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
nn.MaxPool2d(2),
)
self.features = nn.Sequential(
conv_block(3, 64), # 32×32 → 16×16 | 3 channels → 64 feature maps
conv_block(64, 128), # 16×16 → 8×8 | 64 → 128 feature maps
conv_block(128, 256), # 8×8 → 4×4 | 128 → 256 feature maps
)
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d(1), # 4×4×256 → 1×1×256 (Global Average Pooling)
nn.Flatten(), # 1×1×256 → 256
nn.Linear(256, 256),
nn.ReLU(inplace=True),
nn.Dropout(0.4),
nn.Linear(256, 10), # 10 CIFAR-10 classes
)
def forward(self, x):
return self.classifier(self.features(x))
model = CNN().cuda() if torch.cuda.is_available() else CNN()
criterion = nn.CrossEntropyLoss()
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)
n_params = sum(p.numel() for p in model.parameters())
print(f"Parameters: {n_params:,} | Device: {next(model.parameters()).device}")
# ── Training loop ──────────────────────────────────────────────────────────
def evaluate(loader):
model.eval()
correct = total = 0
with torch.no_grad():
for X, y in loader:
X, y = X.to(next(model.parameters()).device), y.to(next(model.parameters()).device)
correct += (model(X).argmax(1) == y).sum().item()
total += y.size(0)
return correct / total
print(f"\n{'Epoch':>5} {'Train Loss':>11} {'Train Acc':>10} {'Test Acc':>9}")
print("-" * 42)
for epoch in range(1, 31):
model.train()
total_loss = 0
for X, y in train_loader:
X, y = X.to(next(model.parameters()).device), y.to(next(model.parameters()).device)
optimizer.zero_grad()
loss = criterion(model(X), y)
loss.backward()
optimizer.step()
total_loss += loss.item()
scheduler.step()
if epoch % 5 == 0:
tr_acc = evaluate(train_loader)
te_acc = evaluate(test_loader)
print(f"{epoch:>5} {total_loss/len(train_loader):>11.4f} {tr_acc:>10.4f} {te_acc:>9.4f}")
1.2M parameters, 87.24% accuracy on 10 classes. CIFAR-10 is a standard benchmark — random chance is 10%, a human achieves roughly 94%, and top models exceed 99%. Our 6-layer CNN with BatchNorm achieves 87% in 30 epochs. The gap to human performance (~7%) is largely closed by deeper architectures, more augmentation, and longer training — not fundamentally different components.
Training accuracy (91.98%) exceeds test accuracy (87.24%) by ~4.7% — mild overfitting, controlled by dropout and weight decay. Without dropout the gap would be 10–15%. Without data augmentation (RandomFlip, RandomCrop), around 5% more.
Global Average Pooling (GAP) instead of Flatten. Instead of flattening 4×4×256 = 4096 values into a dense layer (4096×256 = 1M+ params), GAP averages each of the 256 feature maps to a single number — a 256-vector — and feeds that to the classifier. This saves parameters, acts as a regulariser, and is the standard in modern architectures (ResNet, EfficientNet, ConvNeXt all use it). The final dense layer needs only 256×10 = 2,560 parameters instead of millions.
07 — Residual Connections: How ResNet Fixed Deep Networks
By 2014, researchers were stacking more layers hoping for better accuracy. Something unexpected happened: networks with 56 layers were worse than networks with 20 layers — not just on the test set, but on the training set too. This wasn't overfitting (a test-time phenomenon). The deeper network was genuinely failing to learn, even on the data it was trained on.
The cause was the vanishing gradient problem compounded across extreme depth. By the time gradients propagated back to the early layers through 56 non-linear transformations, they had shrunk to near-zero — and those layers barely updated. Kaiming He and colleagues at Microsoft Research published the fix in 2015: residual connections.
A standard block computes: output = F(x) where F is conv → BN → ReLU → conv → BN.
A residual block computes: output = F(x) + x
The + x is the skip connection — the input bypasses the two-layer transformation and is added directly to the output. This has two critical effects:
1. Gradient highway. During backpropagation, gradients can flow directly through the skip connection without passing through any non-linear transformations. The gradient of F(x) + x with respect to x is ∂F/∂x + 1 — there's always a direct gradient of at least 1, regardless of what F does. Early layers receive strong gradient signals even in 100-layer networks.
2. Learning residuals instead of mappings. Without the skip connection, the block has to learn the full mapping from input to output. With the skip connection, if the identity (do nothing) is the best transformation at some layer, the block simply needs to learn F(x) = 0 — which is easier than learning the identity function from scratch. The network can choose how much each block modifies the input.
import torch
import torch.nn as nn
# ── Residual block (standard ResNet building block) ───────────────────────
class ResidualBlock(nn.Module):
def __init__(self, channels, downsample=False):
super().__init__()
stride = 2 if downsample else 1
self.block = nn.Sequential(
nn.Conv2d(channels, channels, 3, stride=stride, padding=1, bias=False),
nn.BatchNorm2d(channels),
nn.ReLU(inplace=True),
nn.Conv2d(channels, channels, 3, padding=1, bias=False),
nn.BatchNorm2d(channels),
# Note: NO ReLU here — ReLU applied AFTER the skip addition
)
# Skip connection: if downsampling, match spatial and channel dimensions
self.skip = nn.Sequential(
nn.Conv2d(channels, channels, 1, stride=stride, bias=False),
nn.BatchNorm2d(channels),
) if downsample else nn.Identity() # Identity = "add x unchanged"
self.relu = nn.ReLU(inplace=True)
def forward(self, x):
return self.relu(self.block(x) + self.skip(x))
# ↑ F(x) ↑ x (the skip) ← THIS is the residual connection
# ── Verify shapes and gradient flow ──────────────────────────────────────
block = ResidualBlock(64)
x = torch.randn(2, 64, 32, 32) # 2 images, 64 channels, 32×32
out = block(x)
print(f"Input shape: {tuple(x.shape)}")
print(f"Output shape: {tuple(out.shape)} ← same shape (no spatial change)")
print(f"Block params: {sum(p.numel() for p in block.parameters()):,}")
# Show gradient magnitude before vs after skip connections
out.mean().backward()
grad_norm_with_skip = x.grad.abs().mean().item() if x.requires_grad else "N/A"
x2 = torch.randn(2, 64, 32, 32, requires_grad=True)
out2 = block(x2)
out2.mean().backward()
print(f"\nGradient at input (with skip): {x2.grad.abs().mean().item():.6f}")
print(f"Gradient still non-zero — early layers receive training signal")
print(f"\nResNet-50 stacks {8*4 + 6} such blocks → still gets clean gradients at block 1")
ResNet won ImageNet 2015 with 3.57% top-5 error — better than the estimated human error rate of ~5.1% on that benchmark (a frequently cited figure from the 2015 paper by Russakovsky et al.). The depth: 152 layers. Previously, 20 layers was roughly the practical limit without degradation.
ResNet-50 (50 layers) became the standard backbone for computer vision for years and remains widely used today — in medical imaging, autonomous driving, satellite analysis. The skip connection is so effective that variants of it appear in virtually every modern architecture: DenseNet (connects every layer to every other), U-Net (skip connections across encoder and decoder), and Transformers (residual connections around every attention and FFN block, identical concept).
When you use a pre-trained model for transfer learning (Day 17), you're almost certainly using a ResNet or one of its descendants.
08 — The Architecture Evolution: AlexNet to ConvNeXt
Every major CNN architecture from 2012 onwards solved a specific problem in the previous generation. Understanding what each one fixed makes the evolution logical rather than a list to memorise.
What it fixed: proved CNNs with ReLU, dropout, and GPU training could beat hand-crafted features by a large margin.
Key numbers: 60M parameters, 5 conv layers, 3 FC layers, 11×11 kernels in the first layer. ImageNet top-5 error: 15.3% (vs 26.2% runner-up).
Limitations: large kernels, high parameter count in FC layers, no BatchNorm (not invented yet), training took ~5–6 days on two GTX 580 GPUs.
Key idea: replaced large kernels (7×7, 11×11) with stacks of 3×3 convolutions. Two 3×3 layers cover the same receptive field as one 5×5 but with fewer parameters and an extra non-linearity. Three 3×3 layers ≡ one 7×7.
Key numbers: VGG-16 has 138M parameters (nearly all in the FC layers), 16 weight layers. ImageNet top-5: 7.3%.
Legacy: the "stack of 3×3s" principle is still the default. VGG-16's feature extractor (without FC layers) is still a common backbone. Its weakness: 138M params is enormous — FC layers consume most of them.
Key idea: skip connections (output = F(x) + x) — described in §07 above.
Key numbers: ResNet-50 has 25M parameters. ResNet-152: 60M params, 3.57% top-5 error on ImageNet — below estimated human performance. Enabled training 50–1000 layer networks.
Legacy: still the most widely deployed CNN backbone in production systems. ResNet-50 is the "default" when someone says "I used a CNN" without specifying further.
Key idea: depthwise separable convolutions. A standard 3×3×C filter requires 9×C multiplications per position. Split it: first apply a 3×3 filter per channel independently (depthwise), then mix channels with 1×1 convolutions (pointwise). Same receptive field, 8–9× fewer operations.
Key numbers: MobileNetV1 uses 4.2M parameters and 569M multiply-adds vs ResNet-50's 25M params and 4,100M multiply-adds. ~8× faster, small accuracy tradeoff.
Legacy: powers real-time inference on phones, embedded systems, browsers. If you've used Google Lens or a smart camera app, you've used MobileNet.
Key idea: Neural Architecture Search (NAS) to find an efficient base architecture, then compound scaling — systematically scale depth, width, and resolution together using a fixed ratio. Previous approaches scaled only one dimension (just deeper, or just wider).
Key numbers: EfficientNet-B7 achieves 84.3% ImageNet top-1 with 66M params. ResNet-152 achieves ~82% with similar params. EfficientNet-B0 achieves ~77% with only 5.3M params.
Legacy: became the reference accuracy/efficiency tradeoff model for 2019–2022. Still used widely in medical imaging where labelled data is scarce.
Key idea: "modernised" ResNet by adopting design choices from Vision Transformers (ViT) — larger kernels (7×7), fewer but wider layers, LayerNorm instead of BatchNorm, GELU instead of ReLU, inverted bottleneck. Showed that a pure CNN could match or exceed ViT on standard benchmarks when designed thoughtfully.
Key numbers: ConvNeXt-Large achieves 87.5% ImageNet top-1 — competitive with best ViT models at the time.
Legacy: reignited the CNN vs. Transformer debate in computer vision. Most practitioners now use whichever achieves better accuracy/compute for their specific use case.
import torch
import torch.nn as nn
# Architecture comparison — parameters and approximate FLOPS
architectures = [
("AlexNet", 60, 1500, "15.3% error (2012)"),
("VGG-16", 138, 15500, "7.3% error (2014)"),
("ResNet-50", 25, 4100, "6.7% error (2015)"),
("MobileNetV2", 3.4, 300, "6.9% error (2018)"),
("EfficientNet-B4",19, 4200, "3.5% error (2019)"),
("ConvNeXt-Base", 89, 15400, "2.5% error (2022)"),
]
print(f"{'Model':>20} {'Params (M)':>12} {'MAdd (M)':>10} {'ImageNet Result':>22}")
print("-" * 72)
for name, params, madds, result in architectures:
print(f"{name:>20} {params:>12.1f} {madds:>10,} {result:>22}")
09 — CNN vs Dense MLP: The Full Comparison
Now you have everything needed to make the comparison precise and complete. This is the core question of the post — and the answer runs deeper than just "fewer parameters."
Spatial awareness: none. Pixel (0,0) and pixel (223,223) are treated as equally independent. The model has to rediscover spatial relationships from the raw weights.
Parameters for 224×224 image, 1 hidden layer (1000 neurons): 150.5 million — first layer only.
Weight sharing: zero. Every position uses completely independent weights.
Translation invariance: none. Must re-learn a feature for every possible position it might appear.
Position of the same pattern: treated as entirely different input configurations requiring different weights.
When to use on images: never for raw pixels at any practical resolution. Use a CNN backbone first, then a dense head.
Spatial awareness: explicit. Adjacent pixels interact through local filters. The architecture encodes the assumption that nearby pixels are more related than distant ones — which is true for virtually all natural images.
Parameters for 224×224 image, 64 filters (3×3): 1,792. Not 150 million. Not 15 million. 1,792.
Weight sharing: complete. The same filter weights scan every position — same 9 numbers detect a vertical edge anywhere in the image.
Translation invariance: approximate (exact after global average pooling). A cat in the top-left and a cat in the bottom-right activate the same filters.
Position of the same pattern: produces the same filter response regardless of position — because the same weights are used everywhere.
When to use: any image data, video frames, spectrograms, any 2D signal with spatial correlation.
A dense MLP makes no assumptions about the structure of its input. That's its strength for tabular data — where feature 47 genuinely has nothing to do with feature 48. But for images, we know that pixels are spatially correlated, that the same pattern can appear anywhere, and that patterns have hierarchical structure (edges → parts → objects). A CNN bakes all three of these assumptions directly into its architecture. By constraining the model to respect the data's structure, we get a model that's simultaneously smaller, faster, and more accurate — not despite the constraints, but because of them.
This principle — architecture should match data structure — is the deepest lesson in deep learning design. CNNs for spatial data. RNNs/Transformers for sequential data. Graph Neural Networks for relational data. The architecture is an inductive bias; choosing the right one is often the most important design decision you make.
10 — What CNNs Learn: Visualising Filters
One of the remarkable facts about CNNs is that their learned filters are interpretable — at least in the early layers. Because the first-layer filters operate directly on pixel values, you can visualise them as images. What you find is striking: without any human guidance, CNNs trained on natural images learn filters that look like Gabor filters — oriented edge detectors at various angles and frequencies — which are also what the primary visual cortex (V1) in mammalian brains responds to.
This is based on the visualisation work of Zeiler and Fergus (2013), who developed deconvnet to visualise what patterns maximise each neuron's response in a trained AlexNet:
Layer 1: Oriented edges (horizontal, vertical, diagonal), colour blobs, high-frequency patterns. Almost identical to hand-crafted Gabor filters used in traditional CV.
Layer 2: Combinations of edges — corners, curves, crosses, grids. Beginning of abstract patterns.
Layer 3: More complex textures — mesh patterns, repeating structures, some face-like features beginning to emerge.
Layer 4: Class-specific features — dog faces, wheel spokes, flower petals, text characters (if ImageNet contains text).
Layer 5: Whole objects or large parts — entire dog heads, full faces, distinctive object silhouettes.
The hierarchy is real, interpretable, and emerges automatically from gradient descent. No one told the network what an edge is. It discovered that edges are useful — because they are.
11 — A Thought to Sit With
CNNs trained on ImageNet develop internal representations that look remarkably like the mammalian visual cortex — hierarchical, edge-sensitive, progressively abstract. This wasn't engineered. It emerged from gradient descent on a loss function that knows nothing about neuroscience.
In 2015, a ResNet achieved lower error on ImageNet than the estimated human error rate on the same benchmark. We can now diagnose diabetic retinopathy from fundus photographs and detect early-stage lung cancer on CT scans at radiologist-level performance. These models decide whether to flag something a human will then act on — sometimes in minutes that matter.
When a CNN classifies a chest X-ray as "pneumonia present" based on feature activations that no radiologist can name or verify — not because the reasoning is hidden, but because it doesn't exist in any human-legible form — is that trustworthy? Does 95% accuracy justify trust when you cannot inspect the reasoning, only the result? And who is responsible when the model is wrong?
12 — Coming Up Next
Day 17 — Transfer Learning & Fine-Tuning. Training a CNN from scratch on a small dataset rarely works — there aren't enough examples to learn meaningful filters. Transfer learning solves this: start from a network pre-trained on ImageNet (millions of images) and adapt its learned filters to your specific problem with just hundreds or thousands of examples. We'll walk through exactly how this is done — and how this technique powered a brain tumour classification project to 95% accuracy with a dataset too small to train from scratch.
13 — Go Deeper
▶ But what is a convolution? — 3Blue1Brown
YouTube · 23 min · The most visual, intuitive explanation of what convolution computes and why. Watch before reading §02.
▶ CNN Explainer — Georgia Tech / Interactive Tool
Interactive browser demo · Watch a live CNN classify your own images while showing the feature maps at every layer in real time. The best tool for building visual intuition about what each layer is doing.
🔗 CS231n: Convolutional Neural Networks — Stanford
Course notes · The definitive deep-dive into CNN architecture design, receptive fields, and the full parameter count analysis. Read after this post for the formal treatment.
📄 Deep Residual Learning for Image Recognition — He et al. (2015)
arXiv · The ResNet paper. Unusually readable for a landmark paper. Sections 1–3 explain the degradation problem and skip connection solution clearly.
- Why not MLP for images? 150.5M parameters for a single dense layer on a 224×224 image. No spatial awareness — treats pixel (0,0) and pixel (223,223) as equally independent. Must re-learn every pattern for every position. CNNs have 1,792 parameters for the same first layer with 64 filters, because of weight sharing.
- Weight sharing is the core idea. The same 9-number filter scans every position in the image. A vertical edge detector at position (10,20) and at position (100,150) uses the same 9 weights. This encodes translation invariance architecturally — the network doesn't have to discover it from data.
- Convolution = localised dot product slid across the image. Filter values multiplied element-wise with the image patch, summed to one output value. Repeated at every position → one feature map. K filters → K feature maps stacked into a 3D volume.
- Hierarchical abstraction through depth. Layer 1 receptive field: 3×3 (edges). Layer 6: 13×13 (object parts). Layer 10+: 50×50+ (full objects). Deeper neurons respond to larger, more complex patterns because their receptive fields cover more of the original image. This emerges from the architecture, not from engineering.
- Conv → BN → ReLU → Pool is the standard block. Conv: detect patterns. BatchNorm: stabilise activations across channels. ReLU: introduce non-linearity and prevent vanishing gradients. Pool: compress spatial dimensions and provide translation robustness.
- BatchNorm enables depth. Normalises channel activations to mean 0, std 1 across the batch. Prevents gradient magnitude mismatch between channels. Allows 10–100× larger learning rates. Enabled networks of 50–152 layers that were impossible to train without it (introduced by Ioffe and Szegedy, Google, 2015).
- ReLU prevents vanishing gradients in CNNs the same way it does in MLPs: derivative = 1 for active neurons, so gradients pass through unchanged. Apply after every conv layer (or after BN). Without it, stacking convolutions collapses to a single linear transformation.
- ResNet skip connection: output = F(x) + x. Gradient of the skip path = 1 always — gradient cannot vanish below 1 per block. Enables 50–152 layer networks. ResNet-152 achieved lower error than estimated human performance on ImageNet (2015). Still the most widely deployed CNN backbone in production.
- Global Average Pooling instead of Flatten. Averages each feature map to a single number → small fixed-size vector → tiny classification head. Eliminates the massive FC layers (VGG's FC layers: 119M of 138M total params). Resolution-independent. Provides spatial translation invariance at classification time.
- Architecture evolution = solving the current bottleneck. AlexNet: proved CNNs work. VGG: 3×3 stacks > large kernels. ResNet: skip connections solve depth. MobileNet: depthwise separable convolutions for mobile. EfficientNet: compound scaling. ConvNeXt: adopted Transformer design principles. Each advance was motivated by a specific measurable failure of the previous generation.
- Why do CNNs use weight sharing, and what does it enable?
Weight sharing means one filter (e.g., a vertical edge detector) uses the same weights at every spatial position. This achieves translation invariance — the same pattern is recognised anywhere in the image — and reduces parameters by orders of magnitude (1,792 vs 150M+ for the same first layer). It bakes the spatial structure of images directly into the architecture. - What is a feature map, and what does depth do to feature maps?
A feature map is the output of applying one filter across an entire image — one number per position, representing how strongly that filter's pattern appeared there. A layer with K filters produces K feature maps stacked into a 3D volume. With depth, feature maps in later layers represent increasingly abstract patterns: layer 1 feature maps encode edge presence; layer 5 feature maps encode object part configurations. This hierarchy emerges automatically from gradient descent. - What problem did ResNet solve, and how?
The degradation problem: adding layers beyond ~20 made training accuracy worse (not just test accuracy), because vanishing gradients prevented early layers from learning. ResNet adds a skip connection: output = F(x) + x. The gradient of this with respect to x is ∂F/∂x + 1 — there's always a direct gradient path of at least 1. Early layers in 152-layer networks receive strong gradient signals and train effectively. - How many parameters does a 3×3 conv layer with 64 input and 128 output channels have?
3 × 3 × 64 × 128 + 128 (bias) = 73,856 parameters. Compare to a fully-connected layer between 64×H×W and 128×H×W feature maps: that would require 64 × H × W × 128 × H × W parameters — millions for any reasonable H and W. Weight sharing is why CNNs are tractable. - What does Batch Normalisation do and why is it important?
BatchNorm normalises activations across a mini-batch to zero mean and unit variance per channel, then re-scales with learned γ and β. This prevents activation scale mismatch between channels and layers ("internal covariate shift"), allows larger learning rates, and dramatically stabilises training of deep networks. Without it, networks deeper than ~20 layers were impractical. With it, 50–152 layer ResNets train stably as routine. It also provides mild regularisation.
📚 Hands-On Machine Learning with Scikit-Learn and TensorFlow — Aurélien Géron
Chapter 14: Deep Computer Vision Using Convolutional Neural Networks — formal treatment of all topics in this post plus data augmentation and object detection.
📄 Visualizing and Understanding Convolutional Networks — Zeiler and Fergus (2013)
The paper that first systematically visualised what each CNN layer learns. Section 2 (deconvnet method) and Section 4 (feature visualisations) are accessible and illuminating.