Convolutional
Neural Networks
The architecture that cracked computer vision. CNNs encode a powerful structural prior about images — local patterns matter, and the same pattern means the same thing wherever it appears. You'll understand why that matters, build a CNN from scratch, and learn to transfer pretrained knowledge.
Why Convolutions — The Intuition Before the Math
Before CNNs, people tried fully-connected networks on images. A 224×224 RGB image has 224×224×3 = 150,528 input values. A fully-connected first layer with 1000 neurons needs 150 million weights just for the first layer. That's too many parameters to train, and most of them would learn irrelevant combinations — a neuron that detects "cat ear at pixel (112, 45)" is useless for detecting the same ear at pixel (113, 46).
Convolutions solve this with three structural insights that are baked directly into the architecture:
Local connectivity
Each neuron connects only to a small local region of the input (e.g. a 3×3 patch), not the entire image. Edges and corners emerge from local pixel combinations — you don't need global context to detect them.
Weight sharing
The same filter (same weights) is applied to every position in the image. An edge detector at position (10, 10) uses the exact same weights as an edge detector at position (100, 150). This collapses parameters from 150M to a few hundred.
Translation equivariance
If you shift the input, the feature map shifts by the same amount. A cat detector learned from cats in the center of the image naturally detects cats anywhere — the network doesn't need to re-learn this for each position.
Hierarchical features
Layer 1 detects edges. Layer 2 combines edges into corners and curves. Layer 3 combines those into parts (eyes, wheels). Layer 4+ combines parts into objects. Depth creates a hierarchy from pixels to semantics.
Filters, Feature Maps, and Pooling
A convolutional layer applies a set of learned filters to the input. Each filter slides across the image, computing a dot product between the filter weights and the local patch at each position. The result is a feature map — a spatial map of where that filter's pattern was detected.
Output size = ⌊(Input_size − Filter_size + 2×Padding) / Stride⌋ + 1
| Parameter | Effect | Typical values |
|---|---|---|
| Filter size (kernel) | Size of the receptive field. Larger = captures more context per operation | 3×3 (default), 1×1, 5×5, 7×7 (stem) |
| Number of filters | Number of feature maps output. Each filter learns a different pattern | 32, 64, 128, 256 (doubles each block) |
| Stride | How far the filter moves each step. Stride=2 halves spatial dimensions | 1 (default), 2 (downsampling) |
| Padding | Zeros added around input borders to control output size | same (preserve size) or valid (no padding) |
Pooling reduces spatial dimensions — downsampling the feature maps to reduce computation, increase the receptive field, and add a degree of translation invariance. Max pooling takes the maximum value in each region; average pooling takes the mean.
Max Pooling
Takes the maximum value in each 2×2 (or 3×3) window. Retains the strongest activation. Most common in CNNs — preserves detected features while discarding exact position.
Average Pooling
Takes the mean value in each window. Smoother than max pooling. Used in Global Average Pooling (GAP) — averages each feature map to a single value, replacing large FC layers.
Global Average Pooling
Averages each feature map entirely to a single number. Converts (batch, C, H, W) → (batch, C). Dramatically reduces parameters vs flattening. Standard in modern architectures like ResNet.
Receptive field
The region of the original input that contributes to a neuron's output. Each conv+pool layer increases the effective receptive field — deep networks see large context despite small local filters.
import torch
import torch.nn as nn
# Input: batch=8, channels=3, height=32, width=32 (like CIFAR-10)
x = torch.randn(8, 3, 32, 32)
# Conv layer: 3 input channels → 32 filters, 3×3 kernel, padding=1
conv = nn.Conv2d(in_channels=3, out_channels=32,
kernel_size=3, stride=1, padding=1)
out = conv(x)
print("After conv:", out.shape) # (8, 32, 32, 32) — same spatial, 32 channels
# Max pooling 2×2 — halves spatial dimensions
pool = nn.MaxPool2d(kernel_size=2, stride=2)
out = pool(out)
print("After pool:", out.shape) # (8, 32, 16, 16)
# Second conv block
conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
out = pool(conv2(out))
print("After 2nd block:", out.shape) # (8, 64, 8, 8)
# Global average pooling — collapses spatial dims
gap = nn.AdaptiveAvgPool2d(1)
out = gap(out).squeeze(-1).squeeze(-1)
print("After GAP:", out.shape) # (8, 64) — ready for linear classifier
# How many parameters does each conv layer have?
# Conv2d(3, 32, 3×3): 3×32×3×3 + 32 bias = 896 params
# vs FC layer connecting 32×32×3 to 32: 98,304 params — 110× more
n_params_conv = sum(p.numel() for p in conv.parameters())
print(f"Conv params: {n_params_conv}") # 896
Classic Architectures: LeNet, VGG, ResNet — A Conceptual Tour
The history of CNN architectures is a history of solving one fundamental problem at a time: how do you make networks deeper without them failing to train? Each landmark architecture solved a specific bottleneck. Understanding the progression tells you not just what these models do, but why each design choice was necessary.
With skip connection: y = F(x, {Wᵢ}) + x
Gradient through residual block:
∂L/∂x = ∂L/∂y · (∂F/∂x + 1)
import torch
import torch.nn as nn
import torch.nn.functional as F
class ResidualBlock(nn.Module):
"""Basic ResNet block: two 3×3 convs with a skip connection."""
def __init__(self, channels, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(channels, channels, 3, stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(channels)
self.conv2 = nn.Conv2d(channels, channels, 3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(channels)
# If stride > 1, input spatial dims change — need a projection shortcut
self.shortcut = nn.Identity()
if stride != 1:
self.shortcut = nn.Sequential(
nn.Conv2d(channels, channels, 1, stride=stride, bias=False),
nn.BatchNorm2d(channels)
)
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x))) # first conv block
out = self.bn2(self.conv2(out)) # second conv (no activation yet)
out = out + self.shortcut(x) # ADD the skip connection ← the key
out = F.relu(out) # activation after addition
return out
# Test it
block = ResidualBlock(channels=64)
x = torch.randn(4, 64, 16, 16)
out = block(x)
print(out.shape) # (4, 64, 16, 16) — same shape as input
Building a CNN for Image Classification
Now you build one end-to-end. We'll construct a small ResNet-style CNN for CIFAR-10 (10-class image classification, 32×32 colour images). This covers the complete pipeline: architecture design → data loading → training loop → evaluation.
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# ── Architecture ────────────────────────────────────────────────────────
class SmallResNet(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
# Stem: initial feature extraction
self.stem = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(32),
nn.ReLU()
)
# Residual blocks — gradually increase channels, reduce spatial
self.layer1 = self._make_layer(32, n_blocks=2)
self.layer2 = self._make_layer(64, n_blocks=2, downsample=True)
self.layer3 = self._make_layer(128, n_blocks=2, downsample=True)
# Classifier head
self.head = nn.Sequential(
nn.AdaptiveAvgPool2d(1), # global average pool → (B, 128, 1, 1)
nn.Flatten(), # → (B, 128)
nn.Linear(128, num_classes)
)
def _make_layer(self, channels, n_blocks, downsample=False):
layers = []
if downsample:
layers.append(nn.Conv2d(channels//2, channels, 1, stride=2, bias=False))
layers.append(nn.BatchNorm2d(channels))
layers.append(nn.ReLU())
for _ in range(n_blocks):
layers.append(ResidualBlock(channels))
return nn.Sequential(*layers)
def forward(self, x):
x = self.stem(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
return self.head(x)
# ── Data ────────────────────────────────────────────────────────────────
train_tf = transforms.Compose([
transforms.RandomCrop(32, padding=4),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize([0.4914,0.4822,0.4465],[0.247,0.243,0.261])
])
val_tf = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize([0.4914,0.4822,0.4465],[0.247,0.243,0.261])
])
train_loader = DataLoader(datasets.CIFAR10('./data',train=True, transform=train_tf,download=True),batch_size=128,shuffle=True,num_workers=2)
val_loader = DataLoader(datasets.CIFAR10('./data',train=False,transform=val_tf), batch_size=256,num_workers=2)
# ── Training ─────────────────────────────────────────────────────────────
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SmallResNet().to(device)
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100)
criterion = nn.CrossEntropyLoss()
print(f"Model params: {sum(p.numel() for p in model.parameters()):,}")
# ~350K params — achieves ~88% accuracy on CIFAR-10 after 100 epochs
Transfer Learning with Pretrained Models
Training a CNN from scratch requires a large dataset — ImageNet has 1.2 million images. If you have 5,000 images of a custom task, training from scratch won't work well. Transfer learning is the solution: take a model pretrained on ImageNet (which has learned rich, general visual features), and adapt it to your task.
Feature extraction (frozen)
Freeze all pretrained layers. Only train the new classification head. Fast, low data requirement. Works best when your images resemble ImageNet. Risk: features may not be optimal for your domain.
Fine-tuning (unfrozen)
Unfreeze all or most layers after initial training on the head. Train end-to-end with a very small learning rate. Works best when your domain differs from ImageNet. Risk: overfitting if dataset is too small.
Gradual unfreezing
Unfreeze layers progressively from the top (closest to output) down. Train for a few epochs at each stage. Reduces risk of catastrophic forgetting of the pretrained features. Used in ULMFiT and many fine-tuning recipes.
Learning rate for fine-tuning
Use differential learning rates: small lr for early layers (preserve learned features), larger lr for later layers and head (adapt to new task). 10–100× smaller for pretrained layers vs new head.
import torch
import torch.nn as nn
import torchvision.models as models
# ── Load pretrained ResNet-50 ─────────────────────────────────────────
model = models.resnet50(weights=models.ResNet50_Weights.DEFAULT)
# ── Strategy 1: Feature Extraction — freeze everything ───────────────
for param in model.parameters():
param.requires_grad = False # freeze all layers
# Replace the final classification head (1000 classes → your N classes)
n_classes = 5 # e.g. 5-class flower dataset
model.fc = nn.Sequential(
nn.Dropout(0.3),
nn.Linear(model.fc.in_features, n_classes)
)
# Only model.fc parameters have requires_grad=True
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Trainable params: {trainable:,}") # ~10K vs 25M total
# ── Strategy 2: Fine-tuning — unfreeze for second stage ──────────────
# After training the head for ~5 epochs, unfreeze everything
for param in model.parameters():
param.requires_grad = True
# Use differential learning rates
import torch.optim as optim
optimizer = optim.AdamW([
{'params': model.layer1.parameters(), 'lr': 1e-5}, # frozen-ish
{'params': model.layer2.parameters(), 'lr': 1e-5},
{'params': model.layer3.parameters(), 'lr': 5e-5},
{'params': model.layer4.parameters(), 'lr': 1e-4},
{'params': model.fc.parameters(), 'lr': 1e-3}, # head trains fastest
], weight_decay=0.01)