Week 13: Convolutional Neural Networks

The fully-connected networks from Weeks 11–12 treat every input pixel as independent — which throws away the fact that nearby pixels are related. Convolutional neural networks build that spatial structure directly into the architecture, and remain the backbone of most production computer-vision systems, even in an LLM-centric course.

Phase 4 of 8 Week 13 of 26 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain what a convolution computes and why it's more efficient than a fully-connected layer for images
  • Fine-tune a pretrained CNN on a new image dataset
  • Visualize what a trained CNN's filters actually respond to

1. Convolutions, Kernels, Stride & Padding

A convolution slides a small learnable grid of weights — a kernel (e.g. 3×3) — across an image, computing a dot product (Week 2's same operation, again) between the kernel and each patch of pixels it covers. The output is a feature map that lights up wherever that particular pattern (an edge, a texture) appears in the image.

conv_layer.py
import torch.nn as nn

conv = nn.Conv2d(
    in_channels=3,     # RGB image
    out_channels=16,    # learn 16 different 3x3 kernels/filters
    kernel_size=3,
    stride=1,           # how far the kernel moves between applications
    padding=1,          # pad the image so output size matches input size
)

Two parameters control the output's size and behavior: stride — how many pixels the kernel jumps between applications (stride 2 roughly halves the output's spatial size) — and padding — adding a border of zeros around the input so the kernel can be centered on edge pixels without shrinking the output. The crucial efficiency win over a fully-connected layer: the same small kernel is reused across the entire image, so the network learns "what an edge looks like" once, rather than a separate weight for every pixel position.

Weight sharing is the whole point

A fully-connected layer on a 224×224×3 image would need a separate weight for every one of ~150,000 pixels per output neuron. A convolution reuses the same 3×3×3 kernel (27 weights) everywhere in the image — dramatically fewer parameters, and a useful inductive bias: a cat's ear looks like a cat's ear no matter where it appears in the frame.

2. Pooling Layers & Spatial Downsampling

Pooling reduces a feature map's spatial size by summarizing small regions — most commonly max pooling, which keeps only the largest value in each small patch.

pooling.py
pool = nn.MaxPool2d(kernel_size=2, stride=2)   # halves both height and width

model = nn.Sequential(
    nn.Conv2d(3, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
    nn.Flatten(),
    nn.Linear(32 * 8 * 8, 10),   # final classification head, e.g. 10 classes
)

Pooling does two things at once: it reduces computation for every subsequent layer (half the height and width means a quarter of the values to process), and it introduces a small amount of translation invariance — a feature shifted by a pixel or two still tends to produce the same pooled output, which is usually a desirable property for recognizing an object regardless of its exact position.

3. Classic Architectures & a Look at ResNet

Stacking convolution → activation → pooling blocks repeatedly, shrinking spatial size while increasing channel depth, is the pattern behind early CNNs like LeNet. Pushing this much deeper ran into a problem: very deep plain networks actually got worse at training, not better — gradients from Week 11's backpropagation would vanish or become unstable across dozens of stacked layers.

residual_block.py
import torch.nn as nn

class ResidualBlock(nn.Module):
    def __init__(self, channels):
        super().__init__()
        self.conv1 = nn.Conv2d(channels, channels, 3, padding=1)
        self.conv2 = nn.Conv2d(channels, channels, 3, padding=1)
        self.relu = nn.ReLU()

    def forward(self, x):
        identity = x
        out = self.relu(self.conv1(x))
        out = self.conv2(out)
        return self.relu(out + identity)   # the "residual" (skip) connection

ResNet's key idea is the + identity skip connection: instead of forcing each block to learn a full transformation, it only has to learn a residual — the difference from simply passing the input through unchanged. This gives gradients a direct path backward through the shortcut, letting networks train successfully at far greater depth than was previously practical. You'll see this exact same residual-connection idea again in Week 16's transformer architecture — it's not CNN-specific, it's a general fix for training very deep networks.

4. Data Augmentation & Transfer Learning

Training a strong CNN from scratch typically needs far more labeled images than most projects have. Two techniques close that gap.

augmentation_and_transfer.py
import torchvision.transforms as T
from torchvision.models import resnet18, ResNet18_Weights
import torch.nn as nn

# Data augmentation: synthesize variety from the same images
transform = T.Compose([
    T.RandomHorizontalFlip(),
    T.RandomRotation(10),
    T.ColorJitter(brightness=0.2, contrast=0.2),
    T.ToTensor(),
])

# Transfer learning: start from a model already trained on millions of images
model = resnet18(weights=ResNet18_Weights.DEFAULT)
for param in model.parameters():
    param.requires_grad = False           # freeze the pretrained feature extractor

model.fc = nn.Linear(model.fc.in_features, num_classes)   # replace only the final layer

Data augmentation artificially expands a small dataset by applying realistic transformations (flips, rotations, color shifts) that shouldn't change the true label — a cat flipped horizontally is still a cat. Transfer learning reuses a model already trained on a large, general dataset (like ImageNet), freezing its early layers — which have learned generic features like edges and textures useful across almost any image task — and only training a new final layer for your specific classes. This is often the single highest-leverage technique available when you have a small labeled dataset, since it borrows the equivalent of millions of training images' worth of learned structure for free.

5. Visualizing What a CNN Has Learned

CNNs are less of a black box than they first appear — you can directly inspect what a trained filter responds to by extracting a feature map at an intermediate layer.

visualize_feature_maps.py
activations = {}

def hook(module, input, output):
    activations["conv1"] = output.detach()

model.conv1.register_forward_hook(hook)
model(sample_image.unsqueeze(0))   # run one image through the network

feature_maps = activations["conv1"][0]   # shape: (out_channels, H, W)
# Plot each channel of feature_maps as a grayscale image

Early layers typically learn simple, general-purpose detectors — edges, color blobs, basic textures — while deeper layers combine those into detectors for increasingly complex, task-specific patterns (an eye, a wheel, a specific texture of fur). This progression from generic to specific is exactly why transfer learning's "freeze the early layers" strategy in Section 4 works: those early filters are useful across nearly any image task, while only the later, more specialized layers need retraining.

6. Hands-on Exercise

Hands-on

Fine-tune a pretrained CNN and visualize its learned features

Use a small image classification dataset (e.g. a subset of CIFAR-10 or a small custom folder of images with 3–5 classes).

Requirements:

  1. Build a training pipeline with data augmentation (random flips/rotations at minimum).
  2. Load a pretrained ResNet18, freeze its convolutional base, and replace/train only the final classification layer on your dataset.
  3. Train for a modest number of epochs, tracking train/validation accuracy and loss (Week 12's diagnostic curves).
  4. Unfreeze the last convolutional block and fine-tune with a small learning rate for a few more epochs — does accuracy improve further?
  5. Extract and plot feature maps from an early and a late convolutional layer for the same input image, and describe in one paragraph how the patterns differ between the two layers.
Hint

When unfreezing layers for fine-tuning, use a much smaller learning rate than you'd use training from scratch — the pretrained weights are already close to useful, and a large learning rate risks destroying that structure in a few noisy steps.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does weight sharing make a convolutional layer far more parameter-efficient than a fully-connected layer for image input?

A convolution reuses the same small kernel (e.g. a handful of weights) across every position in the image, rather than learning a completely separate weight for every input pixel per output neuron. This means the number of parameters depends on the kernel size, not the image size — vastly fewer parameters for the same visual pattern-detection capability.

Q2

What problem do ResNet's skip connections solve?

Very deep plain networks struggle to train because gradients can vanish or become unstable propagating backward through many stacked layers. A skip connection gives the gradient a direct path around each block, letting each block learn only a residual adjustment rather than a full transformation — enabling networks far deeper than were previously trainable.

Q3

Why does freezing a pretrained CNN's early layers during transfer learning usually work well?

Early convolutional layers tend to learn generic, low-level features (edges, textures, color blobs) that are broadly useful across almost any image recognition task, not specific to the original training classes. Freezing them preserves that reusable structure while only the later, more task-specific layers need retraining on the new, typically smaller dataset.

Q4

Why should data augmentation transformations (like a horizontal flip) preserve the label's meaning?

Augmentation works by teaching the model that certain variations shouldn't change the prediction — a flipped or slightly rotated cat is still a cat. If a transformation actually changed what the correct label should be (e.g. flipping an image of handwritten text, which can change letter meaning), it would train the model on incorrect label/image pairs rather than useful variety.