Deep Learning,
Week 3
Introduction to DL and GenAI, Week 3: images as data, why fully connected networks fail on them, the convolution operation, channels and parameter counts, kernel size, stride and padding with the output-size formula, 1x1 convolutions, pooling, the receptive field and how to build a CNN. Every number here was really run.
Chapters
Images and Why MLPs Fail
Weeks 1 and 2 treated every input as a flat list of numbers. That works for a customer record. It is a poor fit for a picture, whose meaning lives in how neighbouring pixels relate. This chapter shows exactly how the MLP fails on images, and that failure is the whole motivation for convolutional networks.
1.1An image is a grid of numbers
A grayscale image is one grid of brightness values from 0 (black) to 255 (white): one channel. A colour image has three such grids, R, G, B: three channels. A 100x100 RGB image is therefore 3 x 100 x 100 = 30,000 numbers. Models usually rescale to [0, 1] by dividing by 255.
import torch
# a grayscale 5x5 "image": just a grid of brightness values, 0 (black) to 255 (white)
img = torch.tensor([[0, 0, 0, 0, 0],
[0, 255, 255, 255, 0],
[0, 255, 0, 255, 0],
[0, 255, 255, 255, 0],
[0, 0, 0, 0, 0]], dtype=torch.uint8)
print("shape:", tuple(img.shape), " numbers:", img.numel())
rgb = torch.zeros(3, 100, 100) # an RGB image is three such grids (channels)
print("100x100 RGB image:", tuple(rgb.shape), "->", rgb.numel(), "numbers")
print("normalised to [0, 1]:", (img.float() / 255)[1].tolist())shape: (5, 5) numbers: 25
100x100 RGB image: (3, 100, 100) -> 30000 numbers
normalised to [0, 1]: [0.0, 1.0, 1.0, 1.0, 0.0]What we ask computers to do with images
| Task | Question | Output |
|---|---|---|
| Classification | "is this a cat or a dog?" | one label for the image |
| Object detection | "where is the cat, where is the dog?" | bounding boxes + labels |
| Semantic segmentation | "which class is every pixel?" | a label for every pixel |
1.2Why an MLP is the wrong tool
Problem 1: far too many parameters
Flatten a 100x100 RGB image and feed it to a hidden layer of 1000 units: 30 million weights in the first layer alone. A tiny 32x32 colour image into 512 units is already 1.57 million. A convolutional layer with 32 filters of 3x3x3 needs 896:
n_in = 100 * 100 * 3 # a modest 100x100 RGB image flattened
hidden = 1000
print("inputs:", n_in)
print("first MLP layer:", f"{n_in * hidden + hidden:,}", "parameters")
n_in = 32 * 32 * 3 # the small CIFAR-sized image of the notes
print("32x32x3 -> 512 hidden:", f"{n_in * 512 + 512:,}", "(weights 1,572,864 + 512 biases)")
print("conv layer, 32 filters of 3x3x3:", 3 * 3 * 3 * 32 + 32, "parameters (864 weights + 32 biases)")inputs: 30000
first MLP layer: 30,001,000 parameters
32x32x3 -> 512 hidden: 1,573,376 (weights 1,572,864 + 512 biases)
conv layer, 32 filters of 3x3x3: 896 parameters (864 weights + 32 biases)Too many parameters means slow training, huge memory and, above all, overfitting: with that many free numbers the network can memorise the training pictures instead of learning what a cat is.
Problem 2: the spatial structure is thrown away
Flattening writes the pixels in one long row, so two pixels that were neighbours may end up far apart, and the network has no way to know which pixels were adjacent. To make this concrete, shuffle the pixel positions with one fixed random permutation (the same for every image: the pictures become unrecognisable noise to a human) and train again:
import torch, torch.nn as nn, torch.nn.functional as F
torch.manual_seed(0)
S = 12
def make(n): # images with a horizontal (0) or vertical (1) bar
X = torch.zeros(n, 1, S, S); y = torch.randint(0, 2, (n,))
for i in range(n):
c = torch.randint(2, 10, (1,)).item(); r = torch.randint(2, 10, (1,)).item()
if y[i] == 0: X[i, 0, r, c - 2:c + 3] = 1
else: X[i, 0, r - 2:r + 3, c] = 1
return X + 0.05 * torch.randn_like(X), y
Xtr, ytr = make(400); Xte, yte = make(300)
perm = torch.randperm(S * S) # ONE fixed shuffle of the pixel positions
def shuffled(X): # same shuffle for every image: the picture is scrambled
return X.flatten(1)[:, perm].reshape(-1, 1, S, S)
def fit(model, Xa, Xb):
opt = torch.optim.Adam(model.parameters(), 0.01)
for _ in range(200):
opt.zero_grad(); F.cross_entropy(model(Xa), ytr).backward(); opt.step()
return (model(Xb).argmax(1) == yte).float().mean().item()
def mlp(): return nn.Sequential(nn.Flatten(), nn.Linear(S * S, 32), nn.ReLU(), nn.Linear(32, 2))
def cnn(): return nn.Sequential(nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.AdaptiveMaxPool2d(1), nn.Flatten(), nn.Linear(8, 2))
print("MLP on the real pictures :", fit(mlp(), Xtr, Xte))
print("MLP on the scrambled pictures:", fit(mlp(), shuffled(Xtr), shuffled(Xte)))
print("CNN on the real pictures :", fit(cnn(), Xtr, Xte))
print("CNN on the scrambled pictures:", round(fit(cnn(), shuffled(Xtr), shuffled(Xte)), 3))MLP on the real pictures : 1.0
MLP on the scrambled pictures: 1.0
CNN on the real pictures : 1.0
CNN on the scrambled pictures: 0.597The MLP is exactly as good on scrambled pictures as on real ones: it never used the layout. A CNN, which relies on locality, collapses on the scrambled images (to 0.60 here, near guessing for a two-class task). That is the clearest proof that an MLP treats an image as an unordered bag of numbers.
Problem 3: no translation invariance
A cat in the top-left corner and the same cat in the bottom-right light up different input pixels, so an MLP must learn "cat" separately for every position. Section 6.3 measures this.
1.3The two ideas behind CNNs
- To understand a pixel, its neighbours matter most.
- So look through a small window (3x3): a local receptive field.
- An edge looks like an edge anywhere.
- So slide the same filter over the whole image, reusing its weights at every position.
1.4Chapter summary
- An image is a (C, H, W) grid of numbers; RGB has 3 channels.
- MLP on images: too many parameters (30 million for one layer), spatial layout lost (proved by scrambling), no position-independence.
- CNN fixes it with locality and weight sharing.
The Convolution Operation
The convolution operation is simple enough to do by hand, and doing it once makes everything else in this week click.
2.1Slide, multiply, add
A kernel (or filter) is a small learnable matrix, here 3x3. Place it on a 3x3 window of the image, multiply the two element by element, and add everything into one number. Slide the window one step and repeat. The grid of results is the feature map. The image below is dark on the left and bright on the right, and the kernel is built to say "dark on the left, bright on the right":
import torch, torch.nn.functional as F
img = torch.tensor([[0, 0, 0, 9, 9],
[0, 0, 0, 9, 9],
[0, 0, 0, 9, 9],
[0, 0, 0, 9, 9],
[0, 0, 0, 9, 9]], dtype=torch.float32) # dark left, bright right: a vertical edge
k = torch.tensor([[-1, 0, 1],
[-1, 0, 1],
[-1, 0, 1]], dtype=torch.float32) # responds to "dark on the left, bright on the right"
# slide the 3x3 window; at each stop multiply element-wise and add up
out = torch.zeros(3, 3)
for i in range(3):
for j in range(3):
window = img[i:i + 3, j:j + 3]
out[i, j] = (window * k).sum()
print("by hand:\n", out)
print("window at (0,1) =", img[0:3, 1:4].tolist(), "-> sum of products =", (img[0:3, 1:4] * k).sum().item())
lib = F.conv2d(img.reshape(1, 1, 5, 5), k.reshape(1, 1, 3, 3)).squeeze()
print("F.conv2d agrees:", torch.equal(out, lib))by hand:
tensor([[ 0., 27., 27.],
[ 0., 27., 27.],
[ 0., 27., 27.]])
window at (0,1) = [[0.0, 0.0, 9.0], [0.0, 0.0, 9.0], [0.0, 0.0, 9.0]] -> sum of products = 27.0
F.conv2d agrees: TrueThe first column of the output is 0 (that window sits wholly in the dark region: all zeros), and the other columns are 27 (each window contains the boundary). The feature map is a map of where the pattern "vertical edge, dark to bright" occurs. That is the whole idea: a filter is a pattern detector and its output says where the pattern is.
- Hand-designed kernels exist: Sobel detects edges, others blur or sharpen.
- In a CNN the nine numbers are learned by backpropagation, so the network invents the detectors it needs.
import torch, torch.nn.functional as F
img = torch.zeros(1, 1, 8, 8)
img[0, 0, 2:6, 2:6] = 1.0 # a bright square on a dark background
sobel_x = torch.tensor([[-1., 0., 1.], [-2., 0., 2.], [-1., 0., 1.]]).reshape(1, 1, 3, 3)
sobel_y = sobel_x.transpose(2, 3)
gx = F.conv2d(img, sobel_x, padding=1)[0, 0]
gy = F.conv2d(img, sobel_y, padding=1)[0, 0]
mag = (gx ** 2 + gy ** 2).sqrt()
for row in mag:
print(" ".join("#" if v > 1.5 else "." for v in row.tolist()))
print("pixels flagged as edge:", int((mag > 1.5).sum()), "(the response hugs the border of the square, inside and out; flat regions give 0)"). . . . . . . .
. . # # # # . .
. # # # # # # .
. # # . . # # .
. # # . . # # .
. # # # # # # .
. . # # # # . .
. . . . . . . .
pixels flagged as edge: 28 (the response hugs the border of the square, inside and out; flat regions give 0)2.2Convolution or cross-correlation?
The mathematical convolution flips the kernel before sliding it. What deep-learning libraries compute, nn.Conv2d, does not flip: it is technically a cross-correlation. The notes flag this as an exam trick. It does not matter in practice: the kernel is learned, so the network simply learns the flipped version if that is what works.
import torch, torch.nn.functional as F
x = torch.tensor([[1., 2., 3., 4.]]).reshape(1, 1, 1, 4)
k = torch.tensor([[1., 0., -1.]]).reshape(1, 1, 1, 3) # an asymmetric kernel
pytorch = F.conv2d(x, k).flatten() # what nn.Conv2d computes
flipped = F.conv2d(x, k.flip(-1)).flatten() # true mathematical convolution flips the kernel first
print("PyTorch 'convolution' (no flip):", pytorch.tolist())
print("true convolution (kernel flipped):", flipped.tolist())
print("(they differ: the sign flips; for a LEARNED kernel it makes no difference)")PyTorch 'convolution' (no flip): [-2.0, -2.0]
true convolution (kernel flipped): [2.0, 2.0]
(they differ: the sign flips; for a LEARNED kernel it makes no difference)2.3Many filters, many channels
With an RGB input, one filter is really a 3x3x3 block: it has a 3x3 slice for each input channel, the three products-sums are added into one number per position. So one filter gives one feature map, and filters give feature maps, the next layer's channels. The parameter count of a convolution layer:
import torch, torch.nn as nn
conv = nn.Conv2d(in_channels=3, out_channels=32, kernel_size=3)
print("weight shape:", tuple(conv.weight.shape), " = (out_channels, in_channels, kH, kW)")
print("bias shape :", tuple(conv.bias.shape))
print("parameters :", sum(p.numel() for p in conv.parameters()), " = 3*3*3*32 + 32")
x = torch.randn(8, 3, 32, 32) # a batch of 8 RGB images
print("input :", tuple(x.shape))
print("output :", tuple(conv(x).shape), "<- 32 feature maps, each 30x30 (no padding)")
for cin, cout, k in [(1, 8, 3), (8, 16, 3), (64, 128, 3), (64, 64, 1)]:
c = nn.Conv2d(cin, cout, k)
print(f"Conv2d({cin:3d},{cout:3d},k={k}) parameters = {sum(p.numel() for p in c.parameters()):>7,} (= {k}*{k}*{cin}*{cout} + {cout})")weight shape: (32, 3, 3, 3) = (out_channels, in_channels, kH, kW)
bias shape : (32,)
parameters : 896 = 3*3*3*32 + 32
input : (8, 3, 32, 32)
output : (8, 32, 30, 30) <- 32 feature maps, each 30x30 (no padding)
Conv2d( 1, 8,k=3) parameters = 80 (= 3*3*1*8 + 8)
Conv2d( 8, 16,k=3) parameters = 1,168 (= 3*3*8*16 + 16)
Conv2d( 64,128,k=3) parameters = 73,856 (= 3*3*64*128 + 128)
Conv2d( 64, 64,k=1) parameters = 4,160 (= 1*1*64*64 + 64)Two facts to remember: the count depends on the kernel and channels, never on the image size (the same 896 weights run on a 32x32 or a 1000x1000 image), and a common exam slip is forgetting the biases.
2.4What the layers learn: a hierarchy
Nobody tells the network what filters to build. Train a tiny CNN to tell horizontal bars from vertical bars (two filters, plain gradient descent) and then print the learned kernels:
import torch, torch.nn as nn, torch.nn.functional as F
torch.manual_seed(0)
S = 12
def make(n):
X = torch.zeros(n, 1, S, S); y = torch.randint(0, 2, (n,))
for i in range(n):
c = torch.randint(2, 10, (1,)).item(); r = torch.randint(2, 10, (1,)).item()
if y[i] == 0: X[i, 0, r, c - 2:c + 3] = 1 # horizontal bar
else: X[i, 0, r - 2:r + 3, c] = 1 # vertical bar
return X + 0.05 * torch.randn_like(X), y
X, y = make(400)
net = nn.Sequential(nn.Conv2d(1, 2, 3, padding=1, bias=False), nn.ReLU(), nn.AdaptiveMaxPool2d(1), nn.Flatten(), nn.Linear(2, 2))
opt = torch.optim.Adam(net.parameters(), 0.02)
for _ in range(150):
opt.zero_grad(); F.cross_entropy(net(X), y).backward(); opt.step()
print("training accuracy:", (net(X).argmax(1) == y).float().mean().item())
for i, k in enumerate(net[0].weight.detach()):
print(f"learned filter {i}:")
print(k[0].round(decimals=1))training accuracy: 1.0
learned filter 0:
tensor([[-0.5000, -0.9000, -1.0000],
[ 1.0000, 1.3000, 1.3000],
[-0.5000, -0.7000, -0.8000]])
learned filter 1:
tensor([[-0.1000, -0.5000, 1.0000],
[-0.6000, -0.6000, 1.0000],
[-0.1000, -0.5000, 1.0000]])Filter 0 has a bright middle row and dark rows above and below: a horizontal-line detector. Filter 1 has a bright right column and dark left: a vertical-edge detector. Edge detectors emerged from nothing but the loss. In real networks this repeats up the stack: early layers find edges and corners, middle layers combine them into textures and parts, deep layers respond to whole objects (eyes, wheels, faces).
2.5Chapter summary
- Convolution = slide a small kernel, multiply element-wise, add: output says where the pattern is.
Conv2dis cross-correlation (no flip); irrelevant for learned kernels.- Params , independent of the image size.
- Filters are learned; layers build edges, then parts, then objects.
Kernel Size, Stride, Padding
Three dials control how a filter moves and how big the result is: kernel size, stride and padding. Together they fix the output size, a calculation that is asked in almost every exam on this week.
3.1Kernel size
3x3 is the standard (VGG, ResNet). A bigger kernel sees more context but costs more parameters ( per channel pair) and, as Chapter 4 shows, a stack of small kernels reaches the same field for less. Odd sizes (3, 5, 7) have a centre pixel, which keeps padding symmetric.
3.2Stride and padding
- Stride : how many pixels the window jumps. overlaps heavily; roughly halves the output (a learned way to downsample).
- Padding : zeros added all around. "valid" = no padding: the output shrinks. "same" = pad so that the output has the input's size (for ). Padding also keeps the border pixels from being under-used.
3.3The output size formula
Why: the window's top-left corner can start at positions as long as the window still fits in the padded width , i.e. the last start . Counting those starts gives the formula. The notes write it without the floor; the floor matters whenever is not a whole number, and PyTorch rounds down (the window that does not fully fit is dropped). Verified against PyTorch over 240 combinations of , with the worked examples:
import torch, torch.nn as nn
def out_size(H, K, P, S):
return (H + 2 * P - K) // S + 1 # floor division
checked, bad = 0, 0
for H in (7, 8, 28, 32, 33):
for K in (1, 3, 5, 7):
for P in (0, 1, 2, 3):
for S in (1, 2, 3):
if H + 2 * P < K:
continue
real = nn.Conv2d(1, 1, K, stride=S, padding=P)(torch.zeros(1, 1, H, H)).shape[-1]
checked += 1
bad += real != out_size(H, K, P, S)
print("combinations checked:", checked, " mismatches:", bad)
for H, K, P, S in [(32, 5, 0, 1), (28, 3, 1, 1), (28, 3, 0, 2), (32, 3, 0, 2), (7, 3, 0, 2)]:
print(f"H={H:2d} K={K} P={P} S={S} -> (H+2P-K)/S+1 = {(H + 2 * P - K) / S + 1:g} -> floor {out_size(H, K, P, S)}")combinations checked: 240 mismatches: 0
H=32 K=5 P=0 S=1 -> (H+2P-K)/S+1 = 28 -> floor 28
H=28 K=3 P=1 S=1 -> (H+2P-K)/S+1 = 28 -> floor 28
H=28 K=3 P=0 S=2 -> (H+2P-K)/S+1 = 13.5 -> floor 13
H=32 K=3 P=0 S=2 -> (H+2P-K)/S+1 = 15.5 -> floor 15
H= 7 K=3 P=0 S=2 -> (H+2P-K)/S+1 = 3 -> floor 3The notes' example (32x32, K=5, P=0, S=1) gives 28x28. Look at the two stride-2 rows: 13.5 and 15.5 are not integers, so PyTorch drops the leftover and gives 13 and 15. Quick rules: ; and same size; .
"Same" padding: we want at , so , giving : 1 for K=3, 2 for K=5, 3 for K=7.
import torch, torch.nn as nn
x = torch.zeros(1, 1, 28, 28)
for K in (3, 5, 7):
P = (K - 1) // 2
out = nn.Conv2d(1, 1, K, padding=P)(x).shape[-1]
print(f"K={K}: padding P=(K-1)/2={P} output {out}x{out} (same as input: {out == 28})")
print("padding='same' does it for you:", tuple(nn.Conv2d(1, 4, 5, padding="same")(x).shape))
print("padding=0 ('valid'), K=5:", tuple(nn.Conv2d(1, 4, 5)(x).shape), " <- each conv shrinks the image")K=3: padding P=(K-1)/2=1 output 28x28 (same as input: True)
K=5: padding P=(K-1)/2=2 output 28x28 (same as input: True)
K=7: padding P=(K-1)/2=3 output 28x28 (same as input: True)
padding='same' does it for you: (1, 4, 28, 28)
padding=0 ('valid'), K=5: (1, 4, 24, 24) <- each conv shrinks the image3.4The 1x1 convolution
A 1x1 kernel looks at one pixel but across all its channels. It cannot see neighbours; it mixes channels (a learned weighted combination of the feature maps at every position), and it can shrink or grow the number of channels without touching height or width. Its cost is (+ biases), and it is mathematically a Linear layer applied independently at every pixel:
import torch, torch.nn as nn
torch.manual_seed(0)
x = torch.randn(2, 64, 10, 10) # 64 channels on a 10x10 grid
conv = nn.Conv2d(64, 16, kernel_size=1)
print("1x1 conv:", tuple(x.shape), "->", tuple(conv(x).shape), " height and width untouched")
print("parameters:", sum(p.numel() for p in conv.parameters()), " = 64*16 + 16")
# a 1x1 conv is the same linear layer applied at every pixel's channel vector
lin = nn.Linear(64, 16)
lin.weight.data = conv.weight.data.reshape(16, 64); lin.bias.data = conv.bias.data
per_pixel = lin(x.permute(0, 2, 3, 1)).permute(0, 3, 1, 2)
print("identical to a Linear layer at each pixel:", torch.allclose(conv(x), per_pixel, atol=1e-5))
# why use it: shrink channels before an expensive 3x3 conv
direct = nn.Conv2d(256, 256, 3, padding=1)
reduced = nn.Sequential(nn.Conv2d(256, 64, 1), nn.Conv2d(64, 64, 3, padding=1), nn.Conv2d(64, 256, 1))
count = lambda m: sum(p.numel() for p in m.parameters())
print(f"3x3, 256->256 directly: {count(direct):,} via 1x1 bottleneck (256->64->64->256): {count(reduced):,}")1x1 conv: (2, 64, 10, 10) -> (2, 16, 10, 10) height and width untouched
parameters: 1040 = 64*16 + 16
identical to a Linear layer at each pixel: True
3x3, 256->256 directly: 590,080 via 1x1 bottleneck (256->64->64->256): 70,016The last line is why 1x1 convolutions are called a "network-in-network" and are used as bottlenecks in Inception and ResNet (Week 4): squeeze 256 channels to 64, do the expensive 3x3 there, expand back. Parameters fall from 590,080 to 70,016, a factor of 8.4.
3.5Chapter summary
- (the floor matters); same padding at .
- Stride 2 halves the size; "valid" shrinks, "same" preserves.
- 1x1 conv mixes channels and is the bottleneck tool.
Pooling and the Receptive Field
Convolutions find local patterns. To recognise a whole object the network must zoom out: summarise regions into smaller maps, so that later filters see a larger part of the picture. Pooling does the summarising, and the receptive field measures how much of the picture each unit can see.
4.1Pooling
A pooling layer slides a window (usually 2x2, stride 2) over each channel separately and replaces it with one summary: max (keep the strongest response, "the feature was found somewhere in this window") or average. It has no learnable parameters, and halves height and width.
import torch, torch.nn as nn
x = torch.tensor([[1., 3., 2., 0.],
[4., 6., 1., 1.],
[0., 2., 9., 5.],
[1., 1., 3., 7.]]).reshape(1, 1, 4, 4)
mp = nn.MaxPool2d(2) # 2x2 windows, stride 2 by default
ap = nn.AvgPool2d(2)
print("max pool:\n", mp(x).squeeze())
print("avg pool:\n", ap(x).squeeze())
print("parameters in a pooling layer:", sum(p.numel() for p in mp.parameters()))
print("shape:", tuple(x.shape), "->", tuple(mp(x).shape), "(height and width halved)")max pool:
tensor([[6., 2.],
[2., 9.]])
avg pool:
tensor([[3.5000, 1.0000],
[1.0000, 6.0000]])
parameters in a pooling layer: 0
shape: (1, 1, 4, 4) -> (1, 1, 2, 2) (height and width halved)- Fewer values downstream: cheaper computation, fewer parameters in the final linear layer.
- Bigger receptive field: the next 3x3 filter now covers a 6x6 patch of the original.
- Some shift tolerance: a feature that moves within a window gives the same pooled output.
The last point is only approximate. A one-pixel shift inside a window vanishes; a shift that crosses a window border changes the output:
import torch, torch.nn as nn
pool = nn.MaxPool2d(2)
a = torch.zeros(1, 1, 6, 6); a[0, 0, 2, 2] = 5.0 # a bright pixel at (2, 2)
b = torch.zeros(1, 1, 6, 6); b[0, 0, 3, 3] = 5.0 # the same pixel shifted by 1
print("inputs equal:", torch.equal(a, b))
print("after 2x2 max pool, equal:", torch.equal(pool(a), pool(b)), " <- small shifts inside a window disappear")
c = torch.zeros(1, 1, 6, 6); c[0, 0, 1, 1] = 5.0 # shifted by 1 the other way: crosses a window border
print("shift across a window border, equal:", torch.equal(pool(a), pool(c)), " <- pooling is only approximately invariant")inputs equal: False
after 2x2 max pool, equal: True <- small shifts inside a window disappear
shift across a window border, equal: False <- pooling is only approximately invariant4.2The receptive field
The receptive field of a unit is the region of the input that can influence it. One 3x3 layer sees 3; stacking a second 3x3 layer, each of its units sees three neighbours that each see three: . Each extra stride-1 3x3 layer adds 2. A stride or pool makes later layers' steps bigger, so the field grows faster. The recurrence is , ( = distance between neighbouring units, measured in input pixels):
def receptive_field(layers):
"""layers: list of (kernel, stride). Returns the receptive field size after each layer."""
rf, jump, out = 1, 1, []
for k, s in layers:
rf = rf + (k - 1) * jump # each layer adds (k-1) steps of the current jump
jump = jump * s
out.append(rf)
return out
print("one 3x3 conv :", receptive_field([(3, 1)]))
print("two 3x3 convs :", receptive_field([(3, 1)] * 2), " <- equals one 5x5")
print("three 3x3 convs :", receptive_field([(3, 1)] * 3), " <- equals one 7x7")
print("conv3, pool2, conv3 :", receptive_field([(3, 1), (2, 2), (3, 1)]))
print("conv3, pool2 x3 blocks :", receptive_field([(3, 1), (2, 2)] * 3))
C = 64
print()
print(f"parameters with {C} channels in and out (no bias):")
print(" three 3x3 convs:", 3 * (3 * 3 * C * C), " one 7x7 conv:", 7 * 7 * C * C, " (same 7x7 receptive field)")
print(" two 3x3 convs :", 2 * (3 * 3 * C * C), " one 5x5 conv:", 5 * 5 * C * C, " (same 5x5 receptive field)")one 3x3 conv : [3]
two 3x3 convs : [3, 5] <- equals one 5x5
three 3x3 convs : [3, 5, 7] <- equals one 7x7
conv3, pool2, conv3 : [3, 4, 8]
conv3, pool2 x3 blocks : [3, 4, 8, 10, 18, 22]
parameters with 64 channels in and out (no bias):
three 3x3 convs: 110592 one 7x7 conv: 200704 (same 7x7 receptive field)
two 3x3 convs : 73728 one 5x5 conv: 102400 (same 5x5 receptive field)Two 3x3 layers have the receptive field of one 5x5, but with 64 channels they cost 73,728 weights against 102,400 (28% fewer); three 3x3 layers match a 7x7 for 110,592 against 200,704 (45% fewer). They also add extra non-linearities (a ReLU between each pair), so they can represent more. That is why modern networks stack 3x3 convolutions instead of using big kernels.
4.3Chapter summary
- Pooling: window summary, no parameters, halves H and W; max keeps the strongest response.
- Approximate shift tolerance only; crossing a window border changes the output.
- Receptive field grows with depth and strides; stacked 3x3s match big kernels with fewer weights.
Building a CNN
Now assemble the pieces into a complete network and see the payoff on a task where the position of the object changes.
5.1The standard recipe
| Stage | Layers | What it does |
|---|---|---|
| Feature extractor | [Conv -> ReLU -> Pool] repeated | turns pixels into feature maps; spatial size shrinks, channels grow |
| Flatten | Flatten | feature maps into one vector |
| Classifier | Linear -> (ReLU) -> Linear | vector into class scores (logits) |
Trace the shapes of a small CNN for 28x28 grayscale images. The rule to watch: height and width fall, channels rise, until the last Linear layer, whose input size is the number of values after Flatten ():
import torch, torch.nn as nn
net = nn.Sequential(
nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), # 28x28 -> 28x28, 8 channels
nn.MaxPool2d(2), # -> 14x14
nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), # -> 14x14, 16 channels
nn.MaxPool2d(2), # -> 7x7
nn.Flatten(), # -> 16*7*7 = 784
nn.Linear(16 * 7 * 7, 10),
)
x = torch.zeros(1, 1, 28, 28)
print(f"{'input':22s}", tuple(x.shape))
for layer in net:
x = layer(x)
p = sum(q.numel() for q in layer.parameters())
print(f"{layer.__class__.__name__:22s}", f"{str(tuple(x.shape)):18s}", f"params {p}")
print("total parameters:", sum(q.numel() for q in net.parameters()))input (1, 1, 28, 28)
Conv2d (1, 8, 28, 28) params 80
ReLU (1, 8, 28, 28) params 0
MaxPool2d (1, 8, 14, 14) params 0
Conv2d (1, 16, 14, 14) params 1168
ReLU (1, 16, 14, 14) params 0
MaxPool2d (1, 16, 7, 7) params 0
Flatten (1, 784) params 0
Linear (1, 10) params 7850
total parameters: 9098Note where the parameters are. The two convolutions have only 80 and 1,168; the single final Linear has 7,850, 86% of the total. Convolutions are cheap in parameters but expensive in computation (they run at every position); the classifier is the reverse. (The earlier MLP in Week 2 put 92% of its parameters in its first layer.)
5.2A common mistake: the Flatten size
The input of the first Linear must equal channels x height x width at that point. Change an image size, a stride or a padding and you must recompute it, or you get the mat1 and mat2 shapes cannot be multiplied error from Week 2. Tracing shapes with a dummy tensor (as above) is the reliable fix.
5.3Where the CNN wins: when the object moves
The decisive experiment. Images contain a horizontal bar, a vertical bar or a plus. In training, shapes only ever appear in the left part of the image. At test time we check them on the left (seen before) and on the right (a place the network never saw a shape). One MLP and one tiny CNN, same data, same training:
import torch, torch.nn as nn
torch.manual_seed(0)
S = 16
def make(n, side):
"""horizontal bar (0), vertical bar (1) or plus (2) placed in the LEFT or RIGHT part of the image."""
X = torch.zeros(n, 1, S, S); y = torch.randint(0, 3, (n,))
for i in range(n):
c = torch.randint(2, 6, (1,)).item() if side == "left" else torch.randint(9, 13, (1,)).item()
r = torch.randint(3, 13, (1,)).item()
k = y[i].item()
if k == 0: X[i, 0, r, c - 2:c + 3] = 1
elif k == 1: X[i, 0, r - 2:r + 3, c] = 1
else: X[i, 0, r, c - 2:c + 3] = 1; X[i, 0, r - 2:r + 3, c] = 1
return X + 0.1 * torch.randn_like(X), y
Xtr, ytr = make(600, "left") # training: shapes only ever appear on the left
Xl, yl = make(300, "left"); Xr, yr = make(300, "right")
def train(model, epochs=40):
opt = torch.optim.Adam(model.parameters(), 1e-3); loss_fn = nn.CrossEntropyLoss()
for _ in range(epochs):
perm = torch.randperm(len(Xtr))
for i in range(0, len(Xtr), 50):
idx = perm[i:i + 50]
opt.zero_grad(); loss_fn(model(Xtr[idx]), ytr[idx]).backward(); opt.step()
model.eval()
with torch.no_grad():
return [(model(X).argmax(1) == y).float().mean().item() for X, y in ((Xl, yl), (Xr, yr))]
mlp = nn.Sequential(nn.Flatten(), nn.Linear(S * S, 64), nn.ReLU(), nn.Linear(64, 3))
cnn = nn.Sequential(nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.AdaptiveMaxPool2d(1), nn.Flatten(), nn.Linear(16, 3))
for name, m in (("MLP", mlp), ("CNN", cnn)):
left, right = train(m)
print(f"{name}: {sum(p.numel() for p in m.parameters()):6,} parameters test on LEFT {left:.2f} test on RIGHT (never seen there) {right:.2f}")
print("chance level is 0.33")MLP: 16,643 parameters test on LEFT 0.93 test on RIGHT (never seen there) 0.35
CNN: 1,299 parameters test on LEFT 1.00 test on RIGHT (never seen there) 1.00
chance level is 0.33The MLP scores 93% on the left, then 35% on the right: chance level. Having learned "pixel 37 is bright means vertical bar", it has nothing to say about pixel 200. The CNN is perfect in both places, with 13 times fewer parameters (1,299 against 16,643), because its filters learned "this pattern" and apply it everywhere. The global max pool at the end (AdaptiveMaxPool2d) is what turns that equivariance into an answer that does not depend on position.
5.4Chapter summary
- Recipe: [Conv, ReLU, Pool] x n, Flatten, Linear. H and W shrink, channels grow.
- Trace shapes with a dummy tensor; the first Linear's input = C x H x W after Flatten.
- Measured: MLP 35% (chance) on unseen positions, CNN 100% with 13x fewer parameters.
Interview Question Bank
What is likely to be asked from Week 3, with 30-second answers, deeper explanations, a lecture-or-extra tag on each question, and code you can run.
Week 3 mixes numerical questions (output size, parameter counts, receptive field), which reward carefulness, with why questions (why MLPs fail, why 3x3 stacks), which reward a clear sentence. Each answer starts with a 30-second version. Questions are tagged From the notes or Extra.
One clear sentence: what it is.
Why it works, in one picture or three lines.
What goes wrong without it.
6.1Interview heat-map: what is most likely
6.2Why CNNs, and the convolution
Three problems.
inputs: 30000
first MLP layer: 30,001,000 parameters
32x32x3 -> 512 hidden: 1,573,376 (weights 1,572,864 + 512 biases)
conv layer, 32 filters of 3x3x3: 896 parameters (864 weights + 32 biases)MLP on the real pictures : 1.0
MLP on the scrambled pictures: 1.0
CNN on the real pictures : 1.0
CNN on the scrambled pictures: 0.597(1) Too many parameters: a 100x100 RGB image into 1000 hidden units is 30 million weights in layer 1, so slow training and overfitting. (2) Spatial structure is lost: flattening discards which pixels are neighbours; an MLP is exactly as accurate on pixel-scrambled images, proving it never used layout. (3) No translation invariance: a cat at a new position is a new problem; in the experiment an MLP trained on the left side scored 35% (chance) on the right.
Go deeper
- A CNN addresses all three with locality and weight sharing.
Two assumptions about images.
MLP: 16,643 parameters test on LEFT 0.93 test on RIGHT (never seen there) 0.35
CNN: 1,299 parameters test on LEFT 1.00 test on RIGHT (never seen there) 1.00
chance level is 0.33Locality: what matters about a pixel is its neighbourhood, so each unit looks at a small window. Weight sharing: a pattern (an edge) means the same anywhere, so the same filter weights are reused at every position. Result: far fewer parameters, and a pattern learned once is detected everywhere (translation equivariance).
Go deeper
- 3x3 filter = 9 weights (per channel pair) against input_size x output_size for a dense layer.
Slide, multiply, add.
by hand:
tensor([[ 0., 27., 27.],
[ 0., 27., 27.],
[ 0., 27., 27.]])
window at (0,1) = [[0.0, 0.0, 9.0], [0.0, 0.0, 9.0], [0.0, 0.0, 9.0]] -> sum of products = 27.0
F.conv2d agrees: TrueA small learnable matrix, the kernel, is placed on each window of the image; the window and kernel are multiplied element-wise and summed into one number; sliding over all positions gives a feature map showing where the pattern the kernel detects occurs. The kernel's weights are shared across all positions and learned by backpropagation.
Go deeper
- Worked example: an edge image with a vertical-edge kernel gives 0 in the flat region and 27 where the window contains the edge.
nn.Conv2d a true convolution?A filter is a 3-D block.
weight shape: (32, 3, 3, 3) = (out_channels, in_channels, kH, kW)
bias shape : (32,)
parameters : 896 = 3*3*3*32 + 32
input : (8, 3, 32, 32)
output : (8, 32, 30, 30) <- 32 feature maps, each 30x30 (no padding)
Conv2d( 1, 8,k=3) parameters = 80 (= 3*3*1*8 + 8)A filter has one 3x3 slice per input channel (3x3x3 for RGB). At each position the three slice-products are summed into one number, so one filter produces one feature map; filters produce maps = the next layer's channels. Weights tensor shape: .
Go deeper
- Conv2d(3, 32, 3) has weight shape (32, 3, 3, 3) and 896 parameters.
One flip.
PyTorch 'convolution' (no flip): [-2.0, -2.0]
true convolution (kernel flipped): [2.0, 2.0]
(they differ: the sign flips; for a LEARNED kernel it makes no difference)Strictly no: mathematical convolution flips the kernel before sliding it; nn.Conv2d does not flip, so it computes a cross-correlation. It is harmless because the kernel is learned: if the flipped one works better, training finds it.
Go deeper
- It matters only when you use a fixed, hand-made asymmetric kernel.
Move the input; what moves?
MLP: 16,643 parameters test on LEFT 0.93 test on RIGHT (never seen there) 0.35
CNN: 1,299 parameters test on LEFT 1.00 test on RIGHT (never seen there) 1.00
chance level is 0.33Equivariant: shifting the input shifts the feature map by the same amount. Invariant means the final output does not change; a CNN gets that by adding pooling and a global summary at the end (or a global max pool before the classifier), and only approximately.
Go deeper
- In the experiment the global max pool turned equivariant feature maps into a position-independent answer: 100% on a never-seen region.
A fixed edge detector.
. . . . . . . .
. . # # # # . .
. # # # # # # .
. # # . . # # .
. # # . . # # .
. # # # # # # .
. . # # # # . .
. . . . . . . .
pixels flagged as edge: 28 (the response hugs the border of the square, inside and out; flat regions give 0)Sobel is a hand-designed 3x3 kernel that responds to intensity changes in one direction ( for vertical edges); combining the x and y responses outlines objects. A CNN learns such kernels itself: in the bar experiment the first layer learned line and edge detectors without being told.
Go deeper
- Classic computer vision hand-crafted features; deep learning learns them.
6.3Sizes, strides and padding
Conv2d(3, 32, 5). What are the output shape and the parameter count?Use the formula, and remember the biases.
# input 32x32x3, Conv2d(3, 32, kernel_size=5), stride 1, padding 0
H_out = (32 + 2*0 - 5) // 1 + 1 # = 28
params = 5*5*3*32 + 32 # = 2432combinations checked: 240 mismatches: 0
H=32 K=5 P=0 S=1 -> (H+2P-K)/S+1 = 28 -> floor 28
H=28 K=3 P=1 S=1 -> (H+2P-K)/S+1 = 28 -> floor 28Output: , so (32, 28, 28) (32 feature maps). Parameters: (2400 weights + 32 biases). The size does not depend on the image resolution, only on kernel and channels.
Go deeper
- Forgetting the biases or the floor are the two usual slips.
- Verified against PyTorch on 240 combinations of H, K, P, S.
Zeros around the border.
K=3: padding P=(K-1)/2=1 output 28x28 (same as input: True)
K=5: padding P=(K-1)/2=2 output 28x28 (same as input: True)
K=7: padding P=(K-1)/2=3 output 28x28 (same as input: True)
padding='same' does it for you: (1, 4, 28, 28)
padding=0 ('valid'), K=5: (1, 4, 24, 24) <- each conv shrinks the imagePadding adds a border of zeros before convolving. Valid = none: each conv shrinks the output (28x28 with a 5x5 kernel gives 24x24). Same = enough padding that, at stride 1, the output has the input's size: . It stops the feature map shrinking layer by layer and lets border pixels be used as often as central ones.
Go deeper
- Odd kernels (3, 5, 7) pad symmetrically.
It cannot see neighbours.
1x1 conv: (2, 64, 10, 10) -> (2, 16, 10, 10) height and width untouched
parameters: 1040 = 64*16 + 16
identical to a Linear layer at each pixel: True
3x3, 256->256 directly: 590,080 via 1x1 bottleneck (256->64->64->256): 70,016A 1x1 kernel mixes the channels at each pixel (a per-pixel linear layer) without looking at neighbours. Uses: reduce or expand the number of channels cheaply, add non-linearity, and build bottlenecks (256 to 64 channels, a 3x3 there, back to 256: 70,016 weights instead of 590,080). The "network-in-network" idea behind Inception and ResNet.
Go deeper
- Parameters ; verified equal to a Linear layer at every pixel.
Learned versus fixed.
H=32 K=5 P=0 S=1 -> (H+2P-K)/S+1 = 28 -> floor 28
H=28 K=3 P=1 S=1 -> (H+2P-K)/S+1 = 28 -> floor 28
H=28 K=3 P=0 S=2 -> (H+2P-K)/S+1 = 13.5 -> floor 13
H=32 K=3 P=0 S=2 -> (H+2P-K)/S+1 = 15.5 -> floor 15
H= 7 K=3 P=0 S=2 -> (H+2P-K)/S+1 = 3 -> floor 3Both halve the size. Pooling is a fixed summary (no parameters); a stride-2 convolution is a learned downsampling (has weights and non-linearity) and is used in many modern networks (all-convolutional nets, ResNet shortcuts). Pooling is simpler and gives a little shift tolerance.
Go deeper
- Rule of thumb in exams: pooling has 0 parameters, a strided conv does not.
6.4Pooling, receptive fields and shapes
Summarise each window.
max pool:
tensor([[6., 2.],
[2., 9.]])
avg pool:
tensor([[3.5000, 1.0000],
[1.0000, 6.0000]])
parameters in a pooling layer: 0
shape: (1, 1, 4, 4) -> (1, 1, 2, 2) (height and width halved)inputs equal: False
after 2x2 max pool, equal: True <- small shifts inside a window disappear
shift across a window border, equal: False <- pooling is only approximately invariantPooling downsamples: each window (2x2, stride 2) is replaced by its max or average, per channel. It has no learnable parameters. It cuts computation, grows the receptive field of later layers and gives some tolerance to small shifts. Max pooling keeps the strongest response ("the feature is in this window").
Go deeper
- It is only approximately shift-invariant: a shift across a window border changes the output.
Same receptive field, fewer parameters, more non-linearity.
one 3x3 conv : [3]
two 3x3 convs : [3, 5] <- equals one 5x5
three 3x3 convs : [3, 5, 7] <- equals one 7x7
conv3, pool2, conv3 : [3, 4, 8]
conv3, pool2 x3 blocks : [3, 4, 8, 10, 18, 22]
parameters with 64 channels in and out (no bias):
three 3x3 convs: 110592 one 7x7 conv: 200704 (same 7x7 receptive field)
two 3x3 convs : 73728 one 5x5 conv: 102400 (same 5x5 receptive field)Two stacked 3x3 layers have the same 5x5 receptive field but fewer weights ( against ), and a ReLU between them makes the pair more expressive. Three 3x3 equal a 7x7 for against . This is VGGNet's core idea.
Go deeper
- With 64 channels: 73,728 against 102,400 weights, and 110,592 against 200,704.
Count the values after Flatten.
input (1, 1, 28, 28)
Conv2d (1, 8, 28, 28) params 80
ReLU (1, 8, 28, 28) params 0
MaxPool2d (1, 8, 14, 14) params 0
Conv2d (1, 16, 14, 14) params 1168
ReLU (1, 16, 14, 14) params 0
MaxPool2d (1, 16, 7, 7) params 0
Flatten (1, 784) params 0
Linear (1, 10) params 7850
total parameters: 9098Its in_features must equal of the last feature map. Changing the image size, stride, padding or pooling changes that number. Compute it with the output-size formula, or push a dummy tensor through the feature extractor and read the shape. (For 28x28 with two conv+pool blocks and 16 channels: .)
Go deeper
nn.AdaptiveAvgPool2d(1)removes the problem: it always outputs 1x1.
Week 3 Summary Sheet
Week 3 on one page: chapter glance, key terms, pairs to tell apart, a formula sheet with worked numbers, the error messages and traps, a checklist and notes on the source.
7.1Week 3 at a glance
MLP on images: 30M params in one layer, layout lost (scramble test), no position independence. Fix: locality + weight sharing.
Slide a kernel, multiply element-wise, add. One filter = one feature map. Params .
. Same: . 1x1 conv mixes channels (bottleneck).
Pooling: 0 params, halves H, W. Receptive field , . Three 3x3 = one 7x7 for 45% fewer weights.
[Conv, ReLU, Pool] x n, Flatten, Linear. Trace shapes. Measured: MLP 35% on unseen positions, CNN 100% with 13x fewer params.
7.2Key terms
- channel
- one grid of an image / one feature map.
- kernel / filter
- the small learnable matrix.
- feature map
- where a pattern occurs.
- weight sharing
- same kernel at every position.
- stride
- step between windows.
- padding
- zeros around the border.
- pooling
- window summary, no params.
- receptive field
- input region a unit sees.
7.3The pairs you must tell apart
7.4Formula sheet with worked numbers
| Quantity | Formula | Worked example (verified) |
|---|---|---|
| Output size | 32, K=5, P=0, S=1: 28 | 28, K=3, P=0, S=2: floor(13.5) = 13 | |
| Same padding | K=3: 1, K=5: 2, K=7: 3 | |
| Conv parameters | Conv2d(3,32,3): 864 + 32 = 896 | Conv2d(64,128,3): 73,856 | |
| 1x1 conv parameters | Conv2d(64,64,1): 4,160 | |
| Dense layer for comparison | 3072 -> 512: 1,573,376 | |
| Receptive field | 3x3, 3x3: 5 | conv3, pool2, conv3: 8 | |
| Pooling parameters | 0 | MaxPool2d(2): 0 |
7.5Errors and traps
| You see | Cause | Fix |
|---|---|---|
mat1 and mat2 shapes cannot be multiplied after the conv layers | wrong in_features of the first Linear | trace shapes with a dummy tensor; use AdaptiveAvgPool |
Calculated padded input size per channel ... Kernel size can't be greater than actual input size | kernel larger than the (padded) input | add padding or shrink the kernel |
| output size is not an integer in the notes' formula | forgot the floor | PyTorch rounds down |
| accuracy collapses when objects move | MLP / flatten head, no shift tolerance | conv + global pooling, augmentation |
7.6Last-minute checklist
- floor in the output size
- + biases in the parameter count
- params ignore image size
- pooling = 0 params
- locality + weight sharing
- three 3x3 = one 7x7, fewer weights
- 1x1 = channel mixing
- Conv2d = cross-correlation
7.7Corrections and additions to the source notes
| Where | The source says | Better |
|---|---|---|
| Output size | add the floor: (13.5 becomes 13; verified on 240 combinations) | |
| Parameter comparison | "3x3 twice: 9+9 = 18 vs 5x5: 25" | per channel pair; with C channels it is 18C^2 vs 25C^2 (28% fewer at C=64) |
| Translation invariance | "convolution gives translation invariance" | strictly equivariance; invariance comes from pooling / global summary |
| Examples | 32x32x3 MLP vs conv | 1,573,376 (with bias) vs 896: both verified by running |
| MLP vs CNN | claims only | measured: scrambled-pixel test (MLP unchanged, CNN 0.60) and position test (MLP 35%, CNN 100%) |
layout lost
no shift tolerance
weight sharing
one filter, one map
same padding
1x1 conv
halves H, W
approx. invariance
3x3 stacks
VGG idea
Flatten Linear
trace shapes