← All blogs

Deep Learning CourseLesson 9 of 16 32 min

A three by three filter highlighted on an image grid produces one cell of a smaller feature map. One small filter slides over the whole image.

Convolutional Neural Networks for Computer Vision

What you'll learn: how convolutional neural networks (CNNs) see. We start by beating Lesson 5's clothing classifier with a CNN a third of its size, then look inside at the filters it learned. From there: the convolution operation, padding, stride and pooling, and the architectures behind modern vision. The questions along the way show that a convolution is just a matrix with shared weights, prove why shifting an image shifts the output, derive the output-size formula, measure what each neuron can see, and explain how skip connections keep gradients alive in very deep networks.

Before you start: Lessons 1 to 8, especially matrices as functions (Lesson 5) and vanishing gradients (Lesson 7).

A smaller network that sees better

Lesson 5's dense network flattened each 28 × 28 image into 784 numbers and reached about 86% on Fashion-MNIST with about 100,000 weights. This CNN keeps the image as a grid and slides small learned filters over it:

Python
import numpy as np
import torch
from torch import nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

tf = transforms.ToTensor()
train_data = datasets.FashionMNIST("data", train=True, download=True, transform=tf)
test_data = datasets.FashionMNIST("data", train=False, download=True, transform=tf)
train_loader = DataLoader(train_data, batch_size=64, shuffle=True)
test_loader = DataLoader(test_data, batch_size=1000)

torch.manual_seed(0)
cnn = nn.Sequential(
    nn.Conv2d(1, 32, kernel_size=3), nn.ReLU(), nn.MaxPool2d(2),    # 28x28 -> 26x26 -> 13x13
    nn.Conv2d(32, 64, kernel_size=3), nn.ReLU(), nn.MaxPool2d(2),   # 13x13 -> 11x11 -> 5x5
    nn.Flatten(),
    nn.Linear(64 * 5 * 5, 10),
)
optimizer = torch.optim.Adam(cnn.parameters(), lr=1e-3)

for epoch in range(2):
    cnn.train()
    for images, labels in train_loader:
        loss = F.cross_entropy(cnn(images), labels)
        optimizer.zero_grad(); loss.backward(); optimizer.step()
    cnn.eval()
    with torch.no_grad():
        acc = np.mean([(cnn(x).argmax(1) == y).float().mean().item() for x, y in test_loader])
    print(f"epoch {epoch + 1}: test accuracy {acc:.1%}")
print("parameters:", sum(p_.numel() for p_ in cnn.parameters()))
Output
epoch 1: test accuracy 86.3%
epoch 2: test accuracy 87.8%
parameters: 34826

About 88% after two epochs, with 34,826 parameters: better accuracy than Lesson 5's three epochs, from a third of the weights. The architecture itself carries knowledge about images, and that's what this lesson is about.

What just happened

The dense network had to learn, separately for every pixel position, what patterns matter there. A shirt collar at the top-left and the same collar one pixel lower are, to it, unrelated inputs. The CNN builds in two facts about images. Local connectivity: patterns such as edges, corners and textures are made of nearby pixels, so each neuron looks only at a small patch. Weight sharing: a pattern that matters in one place matters everywhere, so the same small filter is used at every position. We can look at the 32 filters the first layer learned, each just 3 × 3 weights, and at what one of them detects in an image:

Python
import matplotlib.pyplot as plt

filters = cnn[0].weight.detach()                  # (32, 1, 3, 3)
image, label = test_data[0]
maps = F.relu(cnn[0](image.unsqueeze(0)))[0].detach()   # 32 feature maps, 26 x 26

fig, axes = plt.subplots(2, 8, figsize=(14, 4))
for i in range(8):
    axes[0, i].imshow(filters[i, 0].numpy(), cmap="gray"); axes[0, i].set_title(f"filter {i}")
    axes[1, i].imshow(maps[i].numpy(), cmap="gray")
for ax in axes.flat: ax.axis("off")
plt.show()
print("one filter's weights:\n", filters[0, 0].numpy().round(2))
Output
one filter's weights:
 [[-0.08  0.2  -0.28]
 [-0.32 -0.05  0.19]
 [-0.03  0.35  0.04]]
Top row: eight learned 3 by 3 filters shown as small grey squares. Bottom row: the feature map each filter makes from a picture of an ankle boot. Some outline the boot's edges, one turns the whole boot black on a white background, and others brighten the sole or the upper.

Each feature map lights up where its filter's pattern appears: some respond to vertical edges, some to horizontal edges, some to bright or dark regions. Nobody designed these filters; backpropagation found them. Let's see exactly what a filter computes.

The convolution operation

Place a small filter (kernel) K over the top-left patch of the image, multiply each filter weight by the pixel beneath it, and add everything up, plus a bias. That's one output number. Slide the filter one step and repeat, row by row. The grid of outputs is a feature map:

S(i,j)=∑m∑nI(i+m, j+n) K(m,n)+bS(i, j) = \sum_m \sum_n I(i + m,\, j + n)\, K(m, n) + b

Each output is a dot product (Lesson 2) between the filter and one patch, so it measures how much that patch looks like the filter. A worked example: a 5 × 5 image, bright (10) on the left and dark (0) on the right, and a vertical-edge filter:

[1010100010101000101010001010100010101000]image I[10−110−110−1]filter Ktop-left patch:(10⋅1+10⋅0+10⋅(−1))×3 rows=0flat: no edgeone step right:(10⋅1+10⋅0+0⋅(−1))×3 rows=30edge!feature map:[03030](the same on all three rows)\begin{gathered} \underset{\text{image } I}{\begin{bmatrix} 10 & 10 & 10 & 0 & 0 \\ 10 & 10 & 10 & 0 & 0 \\ 10 & 10 & 10 & 0 & 0 \\ 10 & 10 & 10 & 0 & 0 \\ 10 & 10 & 10 & 0 & 0 \end{bmatrix}} \qquad \underset{\text{filter } K}{\begin{bmatrix} 1 & 0 & -1 \\ 1 & 0 & -1 \\ 1 & 0 & -1 \end{bmatrix}} \\[14pt] \begin{aligned} \text{top-left patch:}\quad & \big(10 \cdot 1 + 10 \cdot 0 + 10 \cdot (-1)\big) \times 3 \text{ rows} = 0 && \text{flat: no edge} \\ \text{one step right:}\quad & \big(10 \cdot 1 + 10 \cdot 0 + 0 \cdot (-1)\big) \times 3 \text{ rows} = 30 && \text{edge!} \end{aligned} \\[10pt] \text{feature map:}\quad \begin{bmatrix} 0 & 30 & 30 \end{bmatrix} \quad \text{(the same on all three rows)} \end{gathered}
Python
def conv2d(image, kernel, stride=1, padding=0):
    if padding:
        image = np.pad(image, padding)
    f = kernel.shape[0]
    out = (image.shape[0] - f) // stride + 1
    result = np.zeros((out, out))
    for i in range(out):
        for j in range(out):
            patch = image[i * stride:i * stride + f, j * stride:j * stride + f]
            result[i, j] = np.sum(patch * kernel)
    return result

I = np.array([[10, 10, 10, 0, 0]] * 5, dtype=float)
K = np.array([[1, 0, -1]] * 3, dtype=float)
print(conv2d(I, K))

# PyTorch's conv2d computes exactly the same thing
torch_out = F.conv2d(torch.tensor(I)[None, None], torch.tensor(K)[None, None])[0, 0]
print(np.allclose(conv2d(I, K), torch_out.numpy()))
Output
[[ 0. 30. 30.]
 [ 0. 30. 30.]
 [ 0. 30. 30.]]
True

(Strictly, this is cross-correlation; textbook convolution flips the kernel first. Since the kernel weights are learned, the flip makes no difference, and every framework computes it this way.)

Is a convolution a new kind of layer?

Question 1: a convolution looks very different from nn.Linear. Is it really something new?

No. A convolution is linear: convolving the sum of two images gives the sum of their convolutions, and scaling an image scales the output. Lesson 5 showed that every linear function is a matrix. So a convolution is a matrix multiplication, just with a very special matrix. In one dimension, a 3-weight filter (k₀, k₁, k₂) sliding over a 6-number signal is this 4 × 6 matrix:

[k0k1k20000k0k1k20000k0k1k20000k0k1k2]each row is the filter, shifted one placemost entries are zero (local connectivity)the same 3 numbers repeat (weight sharing)\begin{bmatrix} k_0 & k_1 & k_2 & 0 & 0 & 0 \\ 0 & k_0 & k_1 & k_2 & 0 & 0 \\ 0 & 0 & k_0 & k_1 & k_2 & 0 \\ 0 & 0 & 0 & k_0 & k_1 & k_2 \end{bmatrix} \qquad \begin{aligned} &\text{each row is the filter, shifted one place} \\ &\text{most entries are zero (local connectivity)} \\ &\text{the same 3 numbers repeat (weight sharing)} \end{aligned}
Python
k = np.array([1.0, 0.0, -1.0])
signal = np.array([3.0, 5.0, 2.0, 8.0, 1.0, 4.0])
M = np.zeros((4, 6))
for row in range(4):
    M[row, row:row + 3] = k                       # the filter, shifted along each row
print(M @ signal)
print(np.array([signal[i:i + 3] @ k for i in range(4)]))   # sliding the filter: identical
Output
[ 1. -3.  1.  4.]
[ 1. -3.  1.  4.]

A CNN is a dense network whose weight matrices are forced to be mostly zero, with the remaining entries copied along the diagonals. That constraint is why it needs so few parameters, and why it generalises so well on images: it can't waste parameters learning a separate collar detector for every position.

Why does shifting the image shift the output?

Question 2: if a shirt moves two pixels to the right, what happens to the feature maps?

They move two pixels to the right as well, and nothing else changes. This property is called translation equivariance. The proof is a change of variables. Let I'(x, y) = I(x − a, y − b) be the image shifted by (a, b). Then

S′(i,j)=∑m∑nI′(i+m, j+n) K(m,n)=∑m∑nI(i−a+m, j−b+n) K(m,n)=S(i−a, j−b)\begin{aligned} S'(i, j) &= \sum_m \sum_n I'(i + m,\, j + n)\, K(m, n) \\ &= \sum_m \sum_n I(i - a + m,\, j - b + n)\, K(m, n) \\ &= S(i - a,\, j - b) \end{aligned}

The output of the shifted image is the shifted output. The same filter weights are used at every position, so a pattern produces the same response wherever it appears. (At the borders the equality breaks down, because some patches fall off the edge; in the middle it is exact.)

Python
img = torch.zeros(1, 1, 20, 20)
img[0, 0, 5:9, 5:9] = 1.0                                   # a small square
shifted = torch.roll(img, shifts=(3, 2), dims=(2, 3))      # move it down 3, right 2
conv = cnn[0]
with torch.no_grad():
    out, out_shifted = conv(img), conv(shifted)
print(torch.allclose(torch.roll(out, shifts=(3, 2), dims=(2, 3))[..., 4:14, 4:14],
                     out_shifted[..., 4:14, 4:14], atol=1e-6))   # same response, moved
Output
True

Padding, stride and the output size

Without padding each convolution shrinks the image, because the filter can't be centred on border pixels. Padding adds a border of p zeros on every side. Stride s is how far the filter moves each step; stride 2 roughly halves the width and height.

Question 3: how big is the output for any image size n, filter size f, padding p and stride s?

Count the positions where the filter fits. After padding, the image is n + 2p wide. The filter's left edge can start at 0, s, 2s, … and the last start must leave room for the filter, so it can't exceed n + 2p − f. The number of starts in 0, s, 2s, … up to that limit is:

output size=⌊n+2p−fs⌋+128 with f=3, p=0, s=1:28−31+1=2628 with f=3, p=1, s=1:28+2−31+1=28“same” padding28 with f=3, p=1, s=2:⌊272⌋+1=14\begin{gathered} \text{output size} = \left\lfloor \frac{n + 2p - f}{s} \right\rfloor + 1 \\[10pt] \begin{array}{lll} 28 \text{ with } f = 3,\ p = 0,\ s = 1: & \frac{28 - 3}{1} + 1 = 26 & \\[4pt] 28 \text{ with } f = 3,\ p = 1,\ s = 1: & \frac{28 + 2 - 3}{1} + 1 = 28 & \text{“same” padding} \\[4pt] 28 \text{ with } f = 3,\ p = 1,\ s = 2: & \left\lfloor \frac{27}{2} \right\rfloor + 1 = 14 & \end{array} \end{gathered}
Python
x = torch.zeros(1, 1, 28, 28)
for pad, stride in [(0, 1), (1, 1), (1, 2)]:
    out = nn.Conv2d(1, 1, kernel_size=3, padding=pad, stride=stride)(x)
    print(f"padding {pad}, stride {stride}: {tuple(out.shape[2:])}"
          f"   formula: {(28 + 2 * pad - 3) // stride + 1}")
Output
padding 0, stride 1: (26, 26)   formula: 26
padding 1, stride 1: (28, 28)   formula: 28
padding 1, stride 2: (14, 14)   formula: 14

Channels and parameters

A colour image has 3 channels, so each filter in the first layer is actually 3 × 3 × 3: it looks at the same patch in all channels and still outputs one number per position. A layer with C_out filters produces C_out feature maps, which become the channels of the next layer's input:

parameters of a conv layer=(f⋅f⋅Cin+1)⋅Coutour first layer:(3⋅3⋅1+1)⋅32=320our second layer:(3⋅3⋅32+1)⋅64=18,496final dense layer:1600⋅10+10=16,010total 34,826\begin{gathered} \text{parameters of a conv layer} = (f \cdot f \cdot C_{\text{in}} + 1) \cdot C_{\text{out}} \\[10pt] \begin{aligned} \text{our first layer:}\quad & (3 \cdot 3 \cdot 1 + 1) \cdot 32 = 320 \\ \text{our second layer:}\quad & (3 \cdot 3 \cdot 32 + 1) \cdot 64 = 18{,}496 \\ \text{final dense layer:}\quad & 1600 \cdot 10 + 10 = 16{,}010 \qquad \text{total } 34{,}826 \end{aligned} \end{gathered}

Unlike a dense layer, the count doesn't depend on the image size at all: the same filters work on a 28 × 28 image or a 4,000 × 3,000 photo.

Pooling

Max pooling shrinks each feature map by keeping only the largest value in each 2 × 2 window (with stride 2), halving the width and height. It keeps the strongest response ("there's an edge around here") and discards its exact position, so a small shift of the image often doesn't change the pooled output at all. Pooling has no parameters and works on each channel separately:

Python
x = torch.tensor([[1, 3, 2, 1],
                  [4, 6, 5, 2],
                  [3, 1, 1, 0],
                  [2, 2, 4, 3]], dtype=torch.float32)[None, None]
print(F.max_pool2d(x, 2)[0, 0])                      # [[6, 5], [3, 4]]
shifted = torch.roll(x, shifts=1, dims=3)            # shift right by one pixel
print(F.max_pool2d(shifted, 2)[0, 0])                # partly unchanged
Output
tensor([[6., 5.],
        [3., 4.]])
tensor([[4., 6.],
        [3., 4.]])

How much of the image does a neuron see?

Question 4: a first-layer neuron sees a 3 × 3 patch. How much of the image does a neuron deep in the network depend on?

Its receptive field grows with every layer. A second-layer neuron sees a 3 × 3 patch of first-layer outputs, each of which saw 3 × 3 pixels, overlapping by all but one pixel, so together they cover 5 × 5. Pooling multiplies the growth, because each step of a later layer jumps two pixels in the input. Instead of working it out by hand, we can measure it with autograd: backpropagate from a single output neuron to the input image, and see which pixels receive a non-zero gradient. Those are exactly the pixels it depends on. One subtlety: max pooling passes the gradient only to the winning pixel of each window, and ReLU blocks it wherever the input was negative, so for one particular image they hide part of the field. To measure everything a neuron could depend on, we use a stand-in with the same layer sizes, average pooling and no ReLU:

Python
stand_in = nn.Sequential(nn.Conv2d(1, 1, 3), nn.AvgPool2d(2), nn.Conv2d(1, 1, 3), nn.AvgPool2d(2))
for layer in stand_in:
    if isinstance(layer, nn.Conv2d):
        nn.init.constant_(layer.weight, 1.0)          # all-positive weights: no accidental zeros

def receptive_field(layers, size=28):
    x = torch.randn(1, 1, size, size, requires_grad=True)
    out = layers(x)
    centre = (out.shape[2] // 2, out.shape[3] // 2)
    out[0, 0, centre[0], centre[1]].backward()       # gradient of one neuron
    return (x.grad[0, 0].abs().sum(dim=1) > 0).sum().item()   # rows it depends on

for n_layers, name in [(1, "conv1"), (2, "conv1 + pool"), (3, "conv2"), (4, "conv2 + pool")]:
    print(f"after {name:13s} {receptive_field(stand_in[:n_layers])} pixels wide")
Output
after conv1         3 pixels wide
after conv1 + pool  4 pixels wide
after conv2         8 pixels wide
after conv2 + pool  10 pixels wide

This growth is how CNNs build a hierarchy. Early layers, with tiny receptive fields, can only detect edges and colour changes. Middle layers combine those into textures, corners and simple shapes. Deep layers, which see most of the image, respond to object parts and whole objects.

Very deep networks and skip connections

Question 5: if depth helps, why not stack 100 convolutional layers?

Lesson 7 answered half of it: the backward pass multiplies by one layer's derivative after another, and over many layers the gradient vanishes or explodes. Until 2015, networks beyond about 20 layers trained worse than shallower ones. ResNet fixed it with a skip connection: each block learns a correction F(x) and adds it to its own input:

y=F(x)+x∂y∂x=∂F∂x+Ithe identity term: the gradient always has a direct path back\begin{gathered} y = F(x) + x \\[8pt] \frac{\partial y}{\partial x} = \frac{\partial F}{\partial x} + I \qquad \text{the identity term: the gradient always has a direct path back} \end{gathered}

By the chain rule, the gradient through the block is the block's own derivative plus the identity. Even if ∂F/∂x is small, the I passes the gradient straight through, so it reaches the early layers undiminished. Let's compare the gradient reaching the input of 50 stacked layers, with and without skips:

Python
def input_gradient(skip, depth=50, width=64):
    torch.manual_seed(0)
    blocks = [nn.Sequential(nn.Linear(width, width), nn.Tanh()) for _ in range(depth)]
    for block in blocks:
        nn.init.normal_(block[0].weight, std=0.5 / width ** 0.5)
    x = torch.randn(1, width, requires_grad=True)
    h_ = x
    for block in blocks:
        h_ = block(h_) + h_ if skip else block(h_)
    h_.sum().backward()
    return x.grad.norm().item()

print("50 layers, no skips:  ", input_gradient(skip=False))
print("50 layers, with skips:", input_gradient(skip=True))
Output
50 layers, no skips:   2.792244322382038e-15
50 layers, with skips: 52.955322265625

Without skips the gradient that reaches the input is around 10⁻¹⁵, effectively zero; with them it arrives at a healthy size. Skip connections made networks of 50, 152 and even 1,000 layers trainable, and they now appear in nearly every deep architecture, including the transformers of Lesson 12.

Landmark architectures

LeNet-5 (Yann LeCun, 1998) read handwritten digits on bank cheques, with the conv-pool-dense pattern we used. AlexNet (2012) won the ImageNet competition by a huge margin with a deeper CNN, ReLU, dropout and GPUs, and started the deep learning boom. VGG (2014) showed that stacks of small 3 × 3 filters work well: two of them see a 5 × 5 region with fewer parameters than one 5 × 5 filter. ResNet (2015) added skip connections. Later designs such as EfficientNet tuned depth, width and resolution together, and vision transformers (2020) apply the attention mechanism of Lesson 12 to patches of an image.

Transfer learning, revisited

Lesson 1 fine-tuned a pretrained ResNet-18. Now you know what was inside: early layers with general edge and texture filters, deep layers with object-part detectors, and skip connections throughout. Freezing the backbone and training a new head works because those features transfer to almost any image task. With more data, unfreezing the whole network and training it with a small learning rate (fine-tuning) usually does better still.

Python
from torchvision import models

resnet = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
first_filters = resnet.conv1.weight.detach()          # 64 filters, each 3 x 7 x 7
print(first_filters.shape)
print("total parameters:", sum(p_.numel() for p_ in resnet.parameters()))
Output
torch.Size([64, 3, 7, 7])
total parameters: 11689512

Beyond classification

The same building blocks do much more than label images. Object detection (YOLO, Faster R-CNN) predicts a box and a class for every object in a picture. Semantic segmentation (U-Net) labels every pixel, which is widely used for tumours in medical scans. Face recognition maps faces to vectors compared by distance (Lesson 2). And the diffusion models of Lesson 15 use a U-Net to turn noise into images.

Summary

A CNN slides small learned filters over an image, S(i, j) = Σ I(i+m, j+n)·K(m, n) + b, each output a dot product measuring how much a patch looks like the filter. A convolution is a matrix multiplication with a sparse, weight-shared matrix, which is why it needs so few parameters, (f·f·C_in + 1)·C_out per layer regardless of image size. It is translation equivariant: shifting the input shifts the output. The output size is ⌊(n + 2p − f)/s⌋ + 1, pooling shrinks maps and adds tolerance to small shifts, and receptive fields grow with depth, giving a hierarchy from edges to objects. Skip connections, y = F(x) + x, add an identity to every block's derivative so gradients survive very deep networks.

Exercises

1. Write the 3 × 3 filter that detects horizontal edges and apply it to a test image with your conv2d. Then try a 3 × 3 blur filter (all weights 1/9).

2. Build the matrix for a 1D convolution of a 5-weight filter over a 10-number signal with stride 2. What shape is it, and how many of its entries are zero?

3. Compute by hand the output shape after each layer of a network with: conv 5×5 (padding 2), max pool 2, conv 3×3 (no padding), max pool 2, starting from 32 × 32. Check with PyTorch.

4. Work out the receptive field of our CNN's final conv layer by hand, then compare with the autograd measurement.

5. Replace the CNN's MaxPool2d layers with Conv2d layers using stride 2. How do accuracy and the parameter count change?

6. In the skip-connection experiment, try depths 5, 20 and 100, and plot the input gradient norm with and without skips on a log scale.

Found this useful? Share it: LinkedIn X

Comments