Deep Learning · Module 13 of 26 · 9 concepts · about 21 min

Convolutional Neural Networks

Using local, shared filters to learn visual features efficiently.

At work: Seeing: product photo tagging, defect detection on factory lines, medical scans, document and ID scanning.

Convolutions, receptive fields, stride and pooling

What it is

A convolution applies the same small learned filter at every image location, producing a feature map whose values say where a pattern is present. The filter's shared weights mean that an edge detector learned in the upper-left can also detect the same edge in the lower-right. Stacking convolutional layers expands each unit's receptive field: early layers see edges and textures, while later layers combine them into larger structures. Stride or pooling reduces spatial resolution, lowering cost and increasing the semantic scale represented by later features.

Why it matters

Weight sharing gives CNNs translation-aware image features with far fewer parameters than a dense layer over pixels. The architecture's locality assumption is useful when nearby pixels are related, but it is not a guarantee of invariance — cropping, augmentation, and data diversity still determine which shifts and distortions the model handles well.

In the real world: The core of image products: spotting defects on a production line, reading number plates, tagging photos.

Visual example

Interactive receptive field

Select an output cell to reveal its exact input patch, filter multiplication, and sum.

Input feature map

The highlighted 3×3 receptive field is the only part of the input used for the selected output cell.

3×3 edge filter

Select an output position

Σ(patch × kernel) = -1 + 0 + 0 + -1 + 0 + 0 + -1 + 0 + 0 = -3

Why this matters

The same vertical-edge filter is evaluated at every location. Selecting locations makes weight sharing and local receptive fields concrete rather than just naming them.

Common misconception

Wrong idea: a convolution sees the whole image at once.

Correct idea: one convolution output cell sees a small local patch. Depth, stride, and pooling enlarge what later cells can see.

import torch.nn as nn

# (batch, 3, height, width) -> (batch, 64, height/2, width/2)
block = nn.Conv2d(3, 64, kernel_size=3, stride=2, padding=1)

Guessing image shapes after every convolution. Track the output formula or inspect tensors; an off-by-one spatial dimension often only surfaces at the final dense layer.

The convolution operation, precisely

What it is

Place the kernel over the top-left corner of the input, multiply each kernel weight by the input value beneath it, sum the products, add the bias — that is one output value. Slide the kernel one position right and repeat; at the end of the row, move down and start again. The output, a feature map, is a grid of those sums, and a large value means the patch beneath that position resembled the kernel's pattern. The kernel's weights are ordinary learned parameters, updated by backpropagation like any others; nothing about them is hand-designed in a trained network.

Why it matters

It is the operation the rest of the module is arithmetic about. Once one output value is understood as 'a dot product between a small learned vector and a small patch', stride, padding, receptive field and channel stacking are all bookkeeping about *which* patch, and the shape formulas stop needing memorisation.

In the real world: A small filter slides over an image looking for a pattern, like a scratch or a crack.

Visual example

One output value

Each output cell is a dot product between the kernel and the patch under it.

input patch at position (0, 0)
c0c1c2
123
456
789
Σ(patch × kernel) + b
output feature map
c0c1
-4-4
-4-4

The highlighted output cell

kernel = [[1, 0], [0, −1]]

patch = [[1, 2], [4, 5]]

1·1 + 2·0 + 4·0 + 5·(−1) = −4

Slide the same kernel one step right and the next output cell is 2·1 + 3·0 + 5·0 + 6·(−1) = −4.

import torch
import torch.nn.functional as F

x = torch.tensor([[[[1., 2., 3.], [4., 5., 6.], [7., 8., 9.]]]])   # (1,1,3,3)
k = torch.tensor([[[[1., 0.], [0., -1.]]]])                        # (1,1,2,2)
F.conv2d(x, k)
# tensor([[[[-4., -4.], [-4., -4.]]]])
# top-left: 1*1 + 2*0 + 4*0 + 5*(-1) = -4

Expecting nn.Conv2d to perform a mathematical convolution. Deep-learning frameworks compute a cross-correlation — the kernel is not flipped. It makes no practical difference because the weights are learned, but it surprises anyone arriving from signal processing.

Parameter sharing and locality

What it is

A dense layer connects every input to every output, so its parameter count is n_in × n_out and each weight is tied to one specific input position. A convolution has k × k × C_in × C_out weights regardless of the image's height and width, because the same kernel is reused at every position. Two properties follow. The count is independent of resolution: the same layer works on a 64×64 or a 1024×1024 image. And a feature learned anywhere is available everywhere — shift the input and the feature map shifts with it, rather than landing on weights that have never seen that pattern.

Why it matters

This is the entire efficiency argument, and it is a difference of four or five orders of magnitude rather than a tuning gain. It is also a statistical argument, not only a memory one: every position in every training image contributes a gradient to the same kernel, so a convolution effectively has vastly more training examples per parameter than a dense layer does.

In the real world: The same “scratch detector” works anywhere in the photo, so models need far less data.

Visual example

Dense vs. conv

Same input, same output width: the convolution's count does not depend on the image size at all.

first layer on a 32×32×3 image, 64 output features
layerparametersdepends on H, W?
Linear(3072, 64)196,672yes — quadratically
Conv2d(3, 64, k=3)1,792no
same, on 224×224Linear: 9,633,856yes
same, on 224×224Conv2d: 1,792no

Common misconception

Wrong idea: a convolution is a smaller, weaker dense layer.

Correct idea: it is a dense layer with two constraints — look locally, reuse the same weights everywhere — and those constraints are true statements about images.

import torch.nn as nn

dense = nn.Linear(224 * 224 * 3, 224 * 224 * 64)   # absurd, for scale
conv = nn.Conv2d(3, 64, kernel_size=3, padding=1)

sum(p.numel() for p in conv.parameters())          # 1,792
3 * 64 * 3 * 3 + 64                                # 1,792 — k·k·C_in·C_out + C_out
# the same 1,792 weights whether the image is 64x64 or 4096x4096

Reading 'fewer parameters' as 'less computation'. A convolution re-evaluates its small kernel at every one of H×W positions, so a 3×3 layer on a 224×224 map costs roughly 224·224·1,728 multiply-accumulates. Convolutions are parameter-cheap and compute-heavy; that is the trade.

Stride, padding and the output-size formula

What it is

Three settings decide a convolution's output size. The kernel size k sets how much of the input each output cell covers; stride s sets how far the kernel moves between positions, so s=2 samples half as many positions per axis and halves the resolution; padding p adds p rows and columns of zeros on each side so that edge pixels can sit at the centre of a patch. The formula is out = floor((in + 2p − k) / s) + 1. The important special case is k=3, s=1, p=1, which gives out = in exactly — 'same' padding — and is why most architectures can stack many 3×3 layers without tracking sizes at all.

Why it matters

Every shape error in a CNN comes from this formula, and every fix comes from it too. It is also how a network's downsampling schedule is designed: strided convolutions and pooling are the only places resolution changes, so the spatial size at the final flatten is fully determined by the sequence of s values, which is the number the first dense layer's in_features must match.

In the real world: Planning a model's layers so it handles the camera resolution used in the factory.

Visual example

out = floor((in + 2p − k)/s) + 1

Four configurations on the same 32×32 input. Only the last one changes the resolution.

Conv2d on a 32×32 input
k, s, poutput sizewhy
13, 1, 132'same' — the standard building block
23, 1, 030no padding loses k − 1 per axis
35, 1, 232'same' for k=5 needs p=2
43, 2, 116stride halves the resolution
import torch
import torch.nn as nn

def out_size(n, k, s, p):
    return (n + 2 * p - k) // s + 1

out_size(32, 3, 1, 1)   # 32  — 'same': (32 + 2 - 3)//1 + 1
out_size(32, 3, 2, 1)   # 16  — halve the resolution
out_size(32, 5, 1, 0)   # 28  — no padding shrinks by k - 1

x = torch.randn(8, 3, 32, 32)
nn.Conv2d(3, 16, kernel_size=3, stride=2, padding=1)(x).shape
# torch.Size([8, 16, 16, 16])

Assuming stride=2 always exactly halves the size. It halves it only when the arithmetic works out; out_size(31, 3, 2, 1) == 16 but out_size(31, 3, 2, 0) == 15, and the floor silently discards a row and a column at the right and bottom edges.

Pooling

What it is

Pooling downsamples a feature map with a fixed, parameter-free rule. MaxPool2d(2) takes the largest value in each non-overlapping 2×2 window, halving both spatial axes and keeping the strongest response; average pooling takes the mean, producing a smoother, less selective summary. Max pooling buys a small amount of *local* translation invariance — a feature that moves one pixel within its window gives the identical output — and it discards precise position in exchange. Modern architectures often replace intermediate pooling with strided convolutions, which downsample with learned weights instead, but keep one global average pool at the end to reduce each channel's whole map to a single number before the classifier.

Why it matters

Pooling is how a network's compute drops as its channel count rises: halving the resolution quarters the positions, which pays for doubling the channels. The invariance it provides is genuine but small and local, so it is not a substitute for augmentation — and the position information it throws away is exactly what segmentation and detection need, which is why those architectures either avoid it or add paths to recover it.

In the real world: Shrinks image data step by step so models run fast on edge devices like security cameras.

Visual example

MaxPool2d(2)

Pooling keeps the strongest response in each window and discards where exactly it was.

feature map, 4×4
c0c1c2c3
1520
3411
0293
1122
max over each non-overlapping 2×2 window
pooled, 2×2
c0c1
52
29

Why this matters

It is the only parameter-free layer in a CNN, and the one that pays for the growing channel count by shrinking the grid.

Move the 5 anywhere inside its 2×2 window and the output is unchanged — that is the local invariance, and also the position information lost.

import torch
import torch.nn as nn

x = torch.tensor([[[[1., 5., 2., 0.], [3., 4., 1., 1.],
                    [0., 2., 9., 3.], [1., 1., 2., 2.]]]])
nn.MaxPool2d(2)(x)      # tensor([[[[5., 2.], [2., 9.]]]])
nn.AvgPool2d(2)(x)      # tensor([[[[3.25, 1.00], [1.00, 4.00]]]])

nn.AdaptiveAvgPool2d(1)(feature_map).flatten(1).shape   # (batch, C)

Stacking pooling until the spatial size reaches 1 and expecting the classifier to still work. Every pool discards position irreversibly; a 224×224 input pooled six times is 3×3, and a seventh pool leaves a single value per channel with no spatial information left to combine.

Channels

What it is

A convolution over a C_in-channel input does not use a 2-D kernel: each filter is C_in × k × k and covers the full depth of the input at once, so one filter produces one output channel by summing across all input channels as well as the spatial patch. Conv2d(3, 64, 3) therefore holds 64 separate 3×3×3 filters and emits 64 feature maps. C_out is a free architectural choice — how many distinct patterns this layer may look for — and the standard schedule raises it as the resolution falls: 3 → 64 → 128 → 256 → 512, roughly doubling each time the grid halves, which keeps the per-layer compute in the same range.

Why it matters

Channels are where a CNN's capacity lives, and confusing the two dimensions of a weight tensor is the most common source of parameter-count surprise. The k·k·C_in·C_out term means a 3×3 layer from 256 to 512 channels holds 1.18 million weights — vastly more than the early layers — which is why almost all of a CNN's parameters sit near its deep, low-resolution end while almost all of its compute sits near the shallow, high-resolution start.

In the real world: Colour images have 3 channels; medical or satellite images can have many more.

Visual example

Interactive: a different kernel, a different feature

The same machinery with different weights. This horizontal-edge filter fires on the row transition; the vertical filter above would give zero here.

Input feature map

The highlighted 3×3 receptive field is the only part of the input used for the selected output cell.

3×3 edge filter

Select an output position

Σ(patch × kernel) = 2 + 2 + 2 + 0 + 0 + 0 + 0 + 0 + 0 = 6

Why this matters

A layer's channel count is how many of these it runs in parallel. Conv2d(3, 64) is 64 independent filters like this one, each learning its own pattern, all applied at every position.

Common misconception

Wrong idea: more channels means the layer sees more of the image.

Correct idea: every channel sees the same patch. More channels means more different questions asked about that patch; receptive field is what changes how much is seen.

import torch
import torch.nn as nn

conv = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, padding=1)
conv.weight.shape        # torch.Size([64, 3, 3, 3]) — 64 filters, each 3x3x3
conv(torch.randn(8, 3, 32, 32)).shape    # (8, 64, 32, 32)

3 * 3 * 3 * 64 + 64      # 1,792
3 * 3 * 256 * 512 + 512  # 1,180,160 — the deep layers hold the parameters

Reading Conv2d(3, 64, 3) as '3 input channels, 64 filters, applied per channel'. Each filter spans every input channel and collapses them, so the output has 64 channels — not 192. A convolution that does act per channel is a *depthwise* convolution, requested with groups=in_channels.

Receptive field, and why stacking beats a big kernel

What it is

A unit's receptive field is the region of the *original input* that can influence it. One 3×3 layer has a receptive field of 3. Stack a second and each of its inputs already summarises 3 pixels, so the field grows to 5; a third makes it 7. In general, n stacked 3×3 layers at stride 1 reach 2n + 1. Two 3×3 layers therefore see as much as one 5×5 layer, and three see as much as one 7×7 — but with fewer parameters (2·9 = 18 versus 25, and 3·9 = 27 versus 49 per channel pair) and with a nonlinearity between each pair, which a single large kernel cannot have. Strided layers and pooling grow the field much faster, multiplying rather than adding.

Why it matters

It explains a design choice that otherwise looks arbitrary — why modern CNNs are deep stacks of 3×3 kernels rather than shallow stacks of large ones — and it is the number to check when a model cannot see a large object: if the final feature's receptive field is 35 pixels and the object spans 150, no amount of training will fix it. Depth is added for reach as much as for capacity.

In the real world: How a model sees both fine detail and the whole object, like a small dent on a large car panel.

Visual example

Receptive field growth

Stride multiplies, plain stacking adds. Both are cheaper than one large kernel.

3×3, stride 1 (adds 2 per layer)3×3, stride 2 (compounds)parameters per channel pair (÷1)
123456010203040506070number of 3×3 layersreceptive field (pixels)three 3×3 = one 7×7, at 27 params vs 49strided stacks reach whole images

The parameter line grows linearly in depth while the strided field grows geometrically — which is the argument for deep stacks of small kernels.

def receptive_field(layers):
    """layers: list of (kernel_size, stride)."""
    field, jump = 1, 1
    for k, s in layers:
        field += (k - 1) * jump
        jump *= s
    return field

receptive_field([(3, 1)])                        # 3
receptive_field([(3, 1), (3, 1)])                # 5  — as much as one 5x5
receptive_field([(3, 1), (3, 1), (3, 1)])        # 7  — as much as one 7x7
receptive_field([(3, 2), (3, 2), (3, 2), (3, 2)]) # 31 — striding compounds

Assuming every pixel in the receptive field matters equally. The influence is strongly concentrated at the centre and falls off toward the edges, so a nominal field of 31 pixels behaves in practice like a noticeably smaller one — do not plan an architecture with no margin.

A classic CNN, end to end

What it is

The canonical shape is a feature extractor followed by a head. The extractor repeats conv → norm → activation once or twice, then downsamples, raising the channel count as the resolution falls; the head flattens or global-average-pools the final map and applies one or two dense layers to produce logits. Nothing in it is new: the convolutions are module 13, the BatchNorm is module 11, the ReLU is module 3, and the dense head is module 12. Global average pooling is the modern choice over Flatten for the transition, because it makes the head's input size depend only on the channel count and not on the input resolution.

Why it matters

Being able to write this from memory, with correct shapes, is the practical skill the module builds toward. It also makes the downsampling schedule visible as a design decision — where resolution drops, where channels rise, and what the final feature map's size is — which is the single fact the head depends on and the single fact that breaks when the input size changes.

In the real world: The standard design behind early photo-recognition products.

Visual example

Deep learning

Resolution falls, channel count rises, then the head reduces everything to logits.

conv → pool → conv → pool → head

  1. input(B, 3, 32, 32)
  2. conv 32 + BN + ReLU + pool(B, 32, 16, 16)
  3. conv 64 + BN + ReLU + pool(B, 64, 8, 8)
  4. conv 128 + BN + ReLU + pool(B, 128, 4, 4)
  5. global avg pool → Linear(B, 10)logits

Only the pooling and strided layers change the spatial size — the schedule of those layers determines the head's input width.

import torch
import torch.nn as nn

def stage(c_in, c_out):
    return nn.Sequential(
        nn.Conv2d(c_in, c_out, 3, padding=1, bias=False),
        nn.BatchNorm2d(c_out), nn.ReLU(),
        nn.MaxPool2d(2),
    )

model = nn.Sequential(
    stage(3, 32),                       # (B, 32, 16, 16)
    stage(32, 64),                      # (B, 64, 8, 8)
    stage(64, 128),                     # (B, 128, 4, 4)
    nn.AdaptiveAvgPool2d(1),            # (B, 128, 1, 1) — resolution-agnostic
    nn.Flatten(),                       # (B, 128)
    nn.Linear(128, 10),
)
model(torch.randn(8, 3, 32, 32)).shape  # torch.Size([8, 10])

Hard-coding nn.Linear(128 * 4 * 4, 10) after a Flatten. The model then accepts exactly one input resolution, and a 64×64 image produces a shape error at the very last layer. AdaptiveAvgPool2d(1) removes the dependency entirely.

Debugging 'my conv output shape is wrong'

What it is

The diagnosis is always the same: apply out = floor((in + 2p − k)/s) + 1 at every layer and compare against what the tensors actually are. The recurring causes are a specific short list. Padding left at its default of 0, so each 3×3 layer quietly shrinks the map by 2. A stride that does not divide evenly, where the floor discards the last row and column. A Flatten into a Linear whose in_features was computed for a different input resolution. And a (B, C, H, W) versus (B, H, W, C) mix-up after loading data with a library that uses channels-last, which produces a plausible-looking but meaningless result rather than an error.

Why it matters

Shape errors in a CNN surface far from their cause — usually at the final dense layer, several stages downstream of the layer that was wrong. Printing shapes forward through the network finds the first divergence in seconds; reasoning backward from the error message usually blames the head, which is almost never the layer at fault.

In the real world: A frequent error when changing image size for a new camera or phone.

import torch
import torch.nn as nn

x = torch.randn(2, 3, 32, 32)
for i, layer in enumerate(model):
    x = layer(x)
    print(f"{i:2d} {type(layer).__name__:18s} {tuple(x.shape)}")
#  0 Conv2d             (2, 32, 30, 30)   <- padding=0: lost 2 pixels here
#  1 MaxPool2d          (2, 32, 15, 15)
#  2 Conv2d             (2, 64, 13, 13)   <- and 2 more
# the head expected 8x8 and gets 6x6 three stages later

'Fixing' the mismatch by changing the head's in_features to whatever number makes the error go away. The padding bug is still there, every stage is still losing its border, and the next change to the input size breaks it again. Fix the layer that dropped the pixels.