feature map
Tier 3 · Deep Learning & Neural Networks

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.

📚 5 Lessons 🖼 Vision Focus 🔁 Transfer Lab ⏱ ~4 hours
Lesson 5.1

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.

💡
The inductive bias argument An MLP treats all input positions equally — it has to learn from scratch that nearby pixels are related. A CNN bakes this assumption into the architecture. When your inductive bias matches the structure of the data, you need less data and fewer parameters to achieve the same performance. This is why CNNs dominated vision tasks for a decade before transformers caught up.
Lesson 5.2

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[i, j] = Σ_{m,n} Input[i+m, j+n] · Filter[m, n] + bias

Output size = ⌊(Input_size − Filter_size + 2×Padding) / Stride⌋ + 1
For a 28×28 input, 3×3 filter, stride=1, padding=1: output is still 28×28
Key hyperparameters
ParameterEffectTypical values
Filter size (kernel)Size of the receptive field. Larger = captures more context per operation3×3 (default), 1×1, 5×5, 7×7 (stem)
Number of filtersNumber of feature maps output. Each filter learns a different pattern32, 64, 128, 256 (doubles each block)
StrideHow far the filter moves each step. Stride=2 halves spatial dimensions1 (default), 2 (downsampling)
PaddingZeros added around input borders to control output sizesame (preserve size) or valid (no padding)
Pooling layers

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.

Pythonconv layer dimensions — manual calculation then PyTorch
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
Lesson 5.3

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.

1989
LeNet-5 (LeCun et al.)
The original CNN. 5 layers, 60K parameters. Designed for handwritten digit recognition (MNIST). Introduced the Conv → Pool → Conv → Pool → FC pattern. Proved the concept but too small for complex tasks and hard to scale with compute of the era.
Key idea: local connectivity + weight sharing
2012
AlexNet (Krizhevsky, Sutskever, Hinton)
Won ImageNet by a 10-point margin and launched the modern deep learning era. 8 layers, 60M parameters, trained on two GPUs. Key innovations: ReLU (faster training), dropout (regularization), data augmentation at scale. Showed deep CNNs + GPUs + big data worked.
Key idea: deep CNNs on GPUs with ReLU
2014
VGGNet (Simonyan & Zisserman)
Went deeper (16–19 layers) using only 3×3 filters. The key insight: two stacked 3×3 convolutions have the same receptive field as one 5×5, but fewer parameters and more non-linearities. Clean, regular architecture — still used as a baseline and for feature extraction.
Key idea: depth with small filters
2015
ResNet (He et al.)
Solved the degradation problem — adding more layers beyond ~20 made accuracy worse, not better. ResNet introduced residual (skip) connections: output = F(x) + x. Gradients can flow directly through the identity path, enabling training of 50, 101, even 1000-layer networks. Won ImageNet 2015. Residual connections are now in virtually every modern architecture.
Key idea: residual connections → very deep networks
2017+
EfficientNet, ConvNeXt, etc.
Systematic scaling (EfficientNet compounds depth/width/resolution together) and modernized designs (ConvNeXt applies transformer design principles back to CNNs). Still competitive with Vision Transformers on many benchmarks, especially with limited data.
Key idea: principled scaling + design borrowing
The residual block — the most important building block
Without skip connection: y = F(x, {Wᵢ})
With skip connection: y = F(x, {Wᵢ}) + x

Gradient through residual block:
∂L/∂x = ∂L/∂y · (∂F/∂x + 1)
The +1 means gradient flows freely through the identity path, regardless of F's gradients
PythonResNet residual block from scratch
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
Lesson 5.4

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.

Pythoncomplete CNN for CIFAR-10
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
Expected results This architecture, trained for 100 epochs on CIFAR-10, achieves approximately 88–90% validation accuracy. A fully-connected network of similar parameter count achieves ~55%. The structural bias of convolutions gives you ~30 percentage points for free.
Lesson 5.5

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.

🔄
Why it works The early layers of CNNs trained on ImageNet learn generic features — edges, textures, colour gradients — that are useful for almost any vision task. Only the later layers learn ImageNet-specific patterns. By reusing these general features, you effectively train on millions of images even when you only have thousands.
Two transfer strategies
🔒

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.

Pythontransfer learning with ResNet-50 (torchvision)
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)
Practical decision guide <500 images: feature extraction only, heavy augmentation. 500–5K: feature extraction then fine-tune top 2 layers. 5K–50K: fine-tune all layers with low lr. >50K: train from scratch or fine-tune — both viable. Domain far from ImageNet (e.g. X-rays, satellite imagery): fine-tuning often still helps but feature extraction matters less.