EVERYTHING AIAI engineering, made visual
0/28 complete
LESSON 02 · COMPUTER VISION × AI · BUILD

Nine weights.
Every position.

Slide a 3×3 kernel across a 5×5 image — multiply, sum, move — and you have the operation behind every vision model since 2012. Shared weights, local windows, one number per placement. Four filters show what nine numbers can detect before any training, then we build the whole thing in NumPy and collapse it into a single matrix multiply.

75 MIN · 6 CHAPTERS + CHECKPREREQ · PHASE 3 + PHASE 4 · LESSON 01
FIG. 02 / ONE KERNEL, NINE PLACEMENTS, FOUR FILTERS
identity blur sharpen sobel-x
LESSON 02TYPE · BUILD~75 MINPREREQ · PHASE 3 (DEEP LEARNING CORE) · PHASE 4 · LESSON 01 (IMAGE FUNDAMENTALS)ORIGINAL LESSON ↗
THE 60-SECOND VERSIONSee the arithmetic ↓
01 / SHARED WEIGHTS

A kernel is a tiny dense layer that slides.

At every position the 3×3 window meets the same nine weights in a dot product, and the sum becomes one output pixel. A dense layer producing the same 3×3 output from a 5×5 input would need 9 × 25 = 225 weights; the convolution needs 10 including its bias. Scale that to a 224×224×3 image and one dense unit already needs 150,528 weights — 574 MiB for a 1,000-unit layer — while a 64-channel 3×3 conv needs 1,792 parameters.

9 weights × 9 placements = one 3×3 map · 5,376× fewer params than dense
02 / SHAPE ARITHMETIC

Padding keeps the size; stride divides it.

One line decides every layer: H_out = floor((H − K + 2P)/S) + 1. On a 5×5 input with K=3: no padding gives 3×3, a one-pixel zero ring keeps 5×5 at stride 1, and stride 2 with the ring lands on 3×3. The same padding rule for odd K is P = (K − 1)/2, which is why 3×3 kernels are everywhere: smallest odd kernel with a true centre.

5×5 · K=3 → 3×3 (P=0) · 5×5 (P=1) · 3×3 (P=1, S=2)
03 / DEPTH BUYS SIGHT

Every 3×3 layer adds a ring to the field.

Stack two 3×3 convolutions and the second layer's neuron sees 5×5 of the original image; three layers reach 7×7; L layers reach r = 1 + L(K − 1). Two 3×3 layers match one 5×5 with 18 weights per channel pair against 25 — and an extra nonlinearity in between. That is why VGG, ResNet and ConvNeXt go 3×3 all the way down.

r = 1 + 2L → 3, 5, 7 · 2 × 3×3 = 18 weights vs 1 × 5×5 = 25
MENTAL MODEL IN ONE SENTENCE

A convolution buys a weight per pattern instead of a weight per location — then depth stacks patterns into parts and parts into objects. Slide, share, sum; pad to keep shape, stride to shrink it; stack to see more.

By the end you will be able to compute any layer’s output size from H, K, P and S in your head and say why floor() is dangerous; explain the difference between a blur, a sharpen, a Laplacian and the two Sobels and check a kernel’s DC gain at a glance; write convolution twice in NumPy (nested loops and im2col) and know the memory and FLOP cost of each (5.4 MB of columns per 224×224 image; 86,704,128 MACs for a 64-channel 3×3 layer); compute a receptive field for a stack with strides (r = 1 + L(K − 1) → 3, 5, 7, 9); and say precisely what translation equivariance promises and where it stops being exact.

A DENSE LAYER THAT SLIDES

Nine weights.
Every position.

A convolution takes a tiny weight matrix — the kernel — and slides it across the image. At every location it multiplies the kernel into the window underneath and adds the products into a single number. That number is one output pixel, and the same nine weights are reused at every position.

Start with the smallest possible picture of the operation. A 3×3 kernel holds nine weights per input channel. Place it at the top-left corner of an image, multiply each weight by the pixel under it, and sum the nine products — that is a dot product, exactly the operation from Phase 1, applied to a 3×3 window instead of two whole vectors. Slide the kernel one pixel right and do it again. Slide it down a row and do it again. The grid of sums you collect is the output, so a 5×5 input with a 3×3 kernel at stride 1 produces a 3×3 output: nine placements, nine sums.

Why bother with a sliding window instead of a normal dense layer? Do the arithmetic. A 224×224 colour image is 224 × 224 × 3 = 150,528 numbers. One dense layer neuron connected to all of them needs 150,528 weights; a hidden layer of 1,000 such units needs 150,528,000 weights — about 150.5 million parameters, which is 574 MiB in float32 for a single layer, before the network has learned anything. A 3×3 convolution producing 64 channels from those same three input channels needs 64 × 3 × 3 × 3 + 64 = 1,792 parameters — about 7 KiB. The dense layer has 5,376× more weights, and it still has no idea that it is looking at an image.

The problem is not just size. A dense layer treats every pixel position as an independent knob: it learns “bright at row 12, column 40”, not “bright”. Move the cat three pixels to the right and every one of those knobs is wrong. A convolution makes two assumptions instead, both true for photographs:

LOCALITY a pixel is most related to its neighbours → the kernel only reads a 3×3 window PARAMETER SHARING the same pattern can appear anywhere → the same 9 weights are used at every position consequence: translation equivariance shift the input by k pixels → the output shifts by k pixels (the network never has to relearn the cat for each location)

The lesson’s opening example makes the arithmetic concrete. Take this 5×5 input and the source’s 3×3 kernel, slide with no padding and stride 1, and compute the first output pixel by hand:

input X (5×5) kernel W (3×3) 1 2 0 1 2 1 0 -1 0 1 3 1 0 2 0 -2 2 1 0 2 1 1 0 -1 1 0 2 1 3 2 1 1 0 1 Y[0,0] = the top-left 3×3 window ⊙ W 1×1 + 2×0 + 0×(-1) = 1 0×2 + 1×0 + 3×(-2) = -6 2×1 + 1×0 + 0×(-1) = 2 sum = -3 → Y[0,0] = -3

Repeat for all nine placements and the whole 3×3 output falls out. The next chapter is about the sliding schedule; for now the important part is that nothing else is happening — nine multiplies and a sum, 81 multiplies in total, produced by ten numbers (nine weights plus one bias). A dense layer that maps the same 25 inputs to the same 9 outputs would need 9 × 25 = 225 weights and nine separate biases: 234 parameters, 23.4× more, with no sharing and no locality.

Here is the complete output — all nine placements, including the ones that touch the bottom-right corner:

full output Y (3×3, no padding, stride 1) -3 0 3 0 -3 0 1 -2 -3 note: the source's W is a Sobel-x with the sign flipped; flip all nine weights and every number above flips sign — |response| is unchanged

The playground below runs this exact arithmetic live. Drag the window, swap in a blur or an edge kernel, edit a single weight, and watch the output map recompute — the same nine numbers doing the work at all sixteen placements.

The multiply-accumulate, one placement at a time

The 3×3 window on the left meets the nine numbers of the kernel in the middle. Drag the window across the image (or use the sliders), then edit the weights and watch every output cell change at once.

kernel weights sobel-x
window rows 2–4 · cols 2–4 (of 6×6) output cell output[2][2] of 4×4 kernel sum 0 → difference kernel: flat regions respond 0 preset sobel-x — left↔right difference — lights up vertical edges multiply-accumulate (weight × pixel) -1 × 0 = 0 0 × 9 = 0 1 × 9 = 9 -2 × 0 = 0 0 × 9 = 0 2 × 9 = 18 -1 × 0 = 0 0 × 9 = 0 1 × 9 = 9 sum = 36 step-edge sanity check (16×16, 0→1 step, sobel-x, P=1) interior columns 7 and 8 = 4 each · all other interior columns 0 → verified two-sample pulse: the discrete derivative of a step straddles the jump border column 15 = -4 — the zero ring inventing an edge where the bright half meets padding

The output cell is the dot product: nine multiplies, one sum. Change one weight and all sixteen output cells update — that is parameter sharing, visible in a single screen.

Quick check

A 5×5 input goes through a 3×3 kernel with no padding. A dense layer that produced the same 3×3 output would need how many weights — and the convolution?

PADDING, STRIDE, AND THE SIZE FORMULA

One line predicts
every layer’s shape.

Before you can stack convolutions you need to know what size comes out. Four numbers decide it — input size H, kernel K, padding P, stride S — and one formula covers every case you will ever meet.

Padding adds a ring of extra values around the input before the kernel starts sliding. Without it, every convolution shrinks the feature map: a 3×3 kernel at stride 1 removes one row and one column from each side, because the kernel can never centre on the outermost pixels. Stack 20 of those and a 224×224 image is down to 184×184, and any residual connection that expects matching shapes breaks. A ring of zeros (P = 1) lets the kernel centre on pixel (0, 0) and still have three rows and three columns of values to multiply — at the price of inventing values that were never in the image. That trade-off is why padding modes exist: zero (most common), reflect (mirror the edge), replicate (copy the edge) and circular (wrap around, for genuinely periodic data).

Stride is the step size of the slide. Stride 1 visits every position. Stride 2 skips every other column and row, which halves each spatial dimension — the classic way modern CNNs downsample inside the layer instead of with a separate pooling layer. ResNet, ConvNeXt and MobileNet all use strided convolutions in place of max-pool somewhere in the stack.

H_out = floor( (H − K + 2P) / S ) + 1 5×5 input, K = 3 32×32 input P=0 S=1 floor(2/1) + 1 = 3 K=3 P=0 S=1 → 30 (valid) P=1 S=1 floor(4/1) + 1 = 5 K=3 P=1 S=1 → 32 (same) P=1 S=2 floor(4/2) + 1 = 3 K=3 P=1 S=2 → 16 (downsample 2) P=0 S=2 floor(2/2) + 1 = 2 K=2 P=0 S=2 → 16 (pool 2×2) K=7 P=3 S=2 → 16 (big kernel)

Read the middle column as the lesson’s three worked cases with the same 5×5 input and the same 3×3 kernel: with no padding and stride 1 you get a 3×3 output (9 placements); add a one-pixel zero ring and stride 1 keeps the input size, 5×5; keep the ring but stride 2 and the kernel lands on three columns and three rows — also 3×3, but from placements spaced two pixels apart instead of one. Every one of those numbers is floor((5 − 3 + 2P)/S) + 1.

“Same padding” is the common name for choosing P = (K − 1) / 2 when the kernel is odd, which makes H_out = H at stride 1: 3→1, 5→2, 7→3. That is a large part of why 3×3 kernels dominate — they are the smallest odd kernel with a true centre, and they cost one pixel of padding per side. With an even kernel there is no symmetric choice: K = 4 needs P = 2 on one side and P = 1 on the other to keep the size unchanged.

One more habit that pays off immediately: when H + 2P − K is not a multiple of S, the floor silently discards the remainder — no warning, no error, just a feature map one row smaller than you expected. The calculator below flags exactly that case, plus the impossible combinations where the kernel is larger than the padded input.

Output-size calculator

Four numbers decide every layer’s shape. Move the sliders and watch the formula, the cell strip and the warnings agree — including the silent case where the floor throws pixels away.

H = 32 · K = 3 · P = 0 · S = 1

Input rows (32 cells) — the orange band is the first window, the blue band the last one, dashed cells are never covered.

Output rows (30 cells)

H_out = ⌊(H − K + 2P) / S⌋ + 1 = ⌊(32 − 3 + 2×0) / 1⌋ + 1 = ⌊29 / 1⌋ + 1 = 29 + 1 = 30

H + 2P − K = 29 slid positions the kernel can start from; output 30 cells.

formula H_out = ⌊(H − K + 2P) / S⌋ + 1 substitute H = 32 · K = 3 · P = 0 · S = 1 span H + 2P − K = 32 + 0 − 3 = 29 result 30 × 30 same rule odd K → P = (K − 1)/2 = 1 starts 0, 1, 2, 3, 4, 5, … last start 29 memorize this line: H_out = ⌊(H − K + 2P) / S⌋ + 1 a 3×3 layer with P = 1 keeps size; S = 2 halves it.

Floor division is the quiet part: when (H + 2P − K) is not a multiple of S, the remainder is silently thrown away — no error, just a feature map one row smaller than you expected.

Quick check

A 5×5 input, a 3×3 kernel, P=1, S=2. What is the output size — and why is it not 2×2 the way it was with P=0?

HAND-DESIGNED KERNELS

Eight filters, written by hand,
still doing useful work.

Before any training, a kernel is just nine numbers someone chose. Blur, sharpen, edge-detect: each pattern of weights has a job, and the shapes they detect are exactly the shapes a trained first layer rediscovers.

A convolution layer can learn its kernels from data — that is the whole point of a convolutional neural network (CNN). But it is easier to see what a kernel means when you write it by hand. Every design below is one 3×3 matrix, and the property that predicts its behaviour is the sum of its weights, its DC gain:

kernel matrix sum what it detects identity 0 0 0 / 0 1 0 / 0 0 0 1 passes the pixel through box blur 3×3 every weight 1/9 1 average of the window gaussian 3×3 σ = 1, normalized: 1 distance-weighted average 0.08 0.12 0.08 / 0.12 0.20 0.12 / 0.08 0.12 0.08 sharpen 0 -1 0 / -1 5 -1 / 0 -1 0 1 centre vs its neighbours laplacian (edge) 0 -1 0 / -1 4 -1 / 0 -1 0 0 2nd derivative — flat areas → 0 sobel-x -1 0 1 / -2 0 2 / -1 0 1 0 left↔right change → vertical edges sobel-y -1 -2 -1 / 0 0 0 / 1 2 1 0 top↕bottom change → horizontal edges sobel magnitude √(Gx² + Gy²) — edge strength, any orientation

A kernel that sums to 1 preserves the brightness scale: a flat region of value 40 comes out as 40, so blur and identity read as modified photographs. A kernel that sums to 0 is a difference: a flat region of 40s comes out as exactly 0, and only change survives. That single number explains why an edge map is mostly black — most pixels are not an edge.

To see the difference kernels work, point Sobel-x at a 16×16 image that is 0 on the left half and 1 on the right half. At the placement whose window sits one column left of the step, the three columns are 0, 0, 1:

Gx = (-1)·0 + 0·0 + 1·1 = 1 (top row) + (-2)·0 + 0·0 + 2·1 = 2 (middle row, doubled) + (-1)·0 + 0·0 + 1·1 = 1 (bottom row) = 4 Gy = 0 a vertical edge has no top↕bottom change |G| = √(4² + 0²) = 4 one clean edge value on a diagonal window 0 0 1 / 0 1 1 / 1 1 1: Gx = 3 Gy = 3 → |G| = √(9 + 9) = √18 ≈ 4.243 the magnitude mixes the two directions instead of choosing one 16 columns → 16 outputs (P=1). Columns 7 AND 8 both read 4; every other interior column on that row reads 0 — verified live in the first lab. Two columns, not one: the discrete derivative of a step is a two-sample pulse (the central difference at 7 and at 8 both straddle the 0→1 jump). and one honest artifact: the LAST column reads -4, because the zero ring turns "bright right up to the edge" into a bright-to-black edge. Padding invents values, and the kernel responds to them — the same reason border artifacts show up in real edge maps.

Two details in that printout are worth keeping. The response is a pulse two columns wide, not one — edges in pixel space have width, and a single-column expectation is the most common Sobel surprise. And the final column reads −4 purely because of padding: a bright image running straight into the zero ring looks like an edge, which is why border artifacts appear in real edge maps and why practitioners often crop or ignore a one-pixel border after filtering.

The sign of a kernel is a convention, not a fact: flip all nine weights of Sobel-x and every response flips sign — the same edges light up, as negative numbers instead of positive. That is why the source’s W (from the previous chapter) and the standard Sobel-x differ only in sign, and why a magnitude image looks identical either way.

These filters are not toys the deep-learning era left behind. The first convolutional layer of AlexNet (2012) and VGG (2014), trained purely by gradient descent on ImageNet, learned banks of edge and colour-blob detectors that look strikingly like Gabor filters — the same shapes this gallery writes by hand. A good image model needs edge detectors no matter what task comes later, so it builds them.

The kernel gallery

One synthetic image — a vertical step, a bright disc, a diagonal line — through eight hand-designed filters. Pick a filter and read the kernel beside its work; the edge detectors respond exactly where brightness changes.

preset sobel-x detects left↔right difference — lights up vertical edges kernel [ -1 0 1 ] [ -2 0 2 ] [ -1 0 1 ] kernel sum 0 → flat regions respond 0 (edge / difference filter) response min -820 · max 615 · max |response| 820 display signed: orange = positive, red = negative input 20×20 synthetic: left/right step at column 10, disc at (6, 6), diagonal |row − col| ≤ 1 sanity check a flat region (all 40s) through this kernel → 0

Blur and identity keep the brightness scale; sharpen and the edge kernels are differences, so flat regions go to zero and only change survives. That is why a kernel’s sum is worth checking before anything else.

Quick check

A kernel's nine weights sum to exactly 0. You apply it to a patch that is uniformly gray (every pixel 40). What comes out?

CONV2D FROM SCRATCH

Two implementations:
the loop and the matmul.

The nested-loop convolution is the definition, slow but unambiguous. The im2col version is the same arithmetic rearranged so a single matrix multiply can do it — the trick at the heart of every fast conv kernel shipped today.

Build the reference first. The smallest primitive is padding: a function that adds p zeros around an H×W array. The trailing-axes trick x.shape[:-2] means the same function works on (H, W), (C, H, W) or (N, C, H, W) without modification — worth doing once, because every convolution in the rest of the phase will use it.

Step 1 — pad2d, the smallest primitivepython
import numpy as np

def pad2d(x, p):
    """Zero-pad the last two axes. Works for (H, W), (C, H, W) or (N, C, H, W)."""
    if p == 0:
        return x
    h, w = x.shape[-2:]
    out = np.zeros(x.shape[:-2] + (h + 2 * p, w + 2 * p), dtype=x.dtype)
    out[..., p:p + h, p:p + w] = x
    return out
Padding is where the output-size formula gets its 2P: the padded array is (h + 2p) × (w + 2p), and the kernel slides inside that.

Then the definition itself: one output channel, one output row, one output column, and a multiply-accumulate over C_in × K × K values. That is where the classic “quadruple loop” name comes from — oc, i, j, and the implicit sum over the patch.

Step 2 — conv2d_naive, the ground truthpython
def conv2d_naive(x, w, b=None, stride=1, padding=0):
    c_in, h, w_in = x.shape
    c_out, c_in_w, kh, kw = w.shape
    assert c_in == c_in_w

    x_pad = pad2d(x, padding)
    h_out = (h + 2 * padding - kh) // stride + 1
    w_out = (w_in + 2 * padding - kw) // stride + 1

    out = np.zeros((c_out, h_out, w_out), dtype=np.float32)
    for oc in range(c_out):                  # one output channel
        for i in range(h_out):               # one output row
            for j in range(w_out):           # one output column
                patch = x_pad[:, i * stride:i * stride + kh,
                                 j * stride:j * stride + kw]
                out[oc, i, j] = np.sum(patch * w[oc])
        if b is not None:
            out[oc] += b[oc]
    return out
Every faster implementation gets checked against this one. Note w.shape = (C_out, C_in, K, K) — one K×K slice per input channel, per output channel.

The output of a conv layer is a volume, not a map. A real image is (C_in, H, W) — three channels — and one output channel needs one 3×3 slice of weights per input channel. A layer that produces C_out channels stacks C_out of these volumes into a weight tensor of shape (C_out, C_in, K, K). For 64 output channels on a 3-channel input that is 64 × 3 × 3 × 3 = 1,728 weights plus 64 biases: 1,792 parameters, the number the quiz asks you to recognize. The general formula is C_out × C_in × K² + C_out.

Now the trick. Nested Python loops are easy to read but leave the hardware idle; GPUs — graphics processing units — are built to multiply big matrices. im2col — short for image-to-column — rearranges the input so that the convolutionbecomes one matrix multiply: every kernel-sized window of the input is flattened into one column of a matrix, and the kernel is flattened into a row. The lazy version of the same loops still extracts the windows — but the heavy lifting afterwards is a single @.

Step 3 — im2col, then one matmulpython
def im2col(x, kh, kw, stride=1, padding=0):
    """Every kernel-sized window becomes one column: C_in·K² numbers per column."""
    c_in, h, w = x.shape
    x_pad = pad2d(x, padding)
    h_out = (h + 2 * padding - kh) // stride + 1
    w_out = (w + 2 * padding - kw) // stride + 1

    cols = np.zeros((c_in * kh * kw, h_out * w_out), dtype=x.dtype)
    col = 0
    for i in range(h_out):
        for j in range(w_out):
            patch = x_pad[:, i * stride:i * stride + kh,
                             j * stride:j * stride + kw]
            cols[:, col] = patch.reshape(-1)
            col += 1
    return cols, h_out, w_out


def conv2d_im2col(x, w, b=None, stride=1, padding=0):
    c_out, c_in, kh, kw = w.shape
    cols, h_out, w_out = im2col(x, kh, kw, stride, padding)
    out = w.reshape(c_out, -1) @ cols          # ONE matrix multiply
    if b is not None:
        out += b[:, None]
    return out.reshape(c_out, h_out, w_out)


# the correctness check: both implementations must agree
rng = np.random.default_rng(0)
x = rng.normal(0, 1, (3, 16, 16)).astype(np.float32)
w = rng.normal(0, 1, (8, 3, 3, 3)).astype(np.float32)
b = rng.normal(0, 1, (8,)).astype(np.float32)

y_naive = conv2d_naive(x, w, b, padding=1)
y_fast = conv2d_im2col(x, w, b, padding=1)
print(f"max abs diff: {np.max(np.abs(y_naive - y_fast)):.2e}")   # ~1e-5
The 1e-5 gap is floating-point accumulation order, not a bug: the naive loop sums 27 terms in one order, the matmul sums them in another. Both are correct answers to the same question.

Unfold the image, then multiply once

The source’s 5×5 example with its 3×3 kernel. Every window slides into one column of the cols matrix and one row of the output; the kernel flattens into a single row. Press unfold and watch a quadruple loop turn into one matmul.

step 3 / 9 columns unfolded current position (row 0, col 2) column [0, 1, 2, 3, 1, 0, 0, 2, 1] kernel row [1, 0, -1, 2, 0, -2, 1, 0, -1] dot product 1×0 + 0×1 + -1×2 + 2×3 + 0×1 + -2×0 + 1×0 + 0×2 + -1×1 = 3 this toy conv (1 channel, 5×5, K=3, S=1, P=0) cols 9 rows × 9 columns = 81 numbers 324 B input 25 numbers 100 B · cols are 3.2× the input the real image (3×224×224, K=3, S=1, P=1) cols 27 rows × 50,176 columns = 1,354,752 floats 5.17 MiB (9× the image) batch 64 330.75 MiB for one layer's unfolded input macs 86,704,128 multiply-accumulates (a MAC = one multiply + one add; 0.17 GFLOP if counted as two floating-point ops) formula FLOPs = H_out · W_out · C_out · C_in · K² = 224 · 224 · 64 · 3 · 9 = 86,704,128

im2col does not compute anything by itself — it rearranges memory so the GPU can run one big matrix multiply. The price is memory: the cols matrix holds every window again, so it is C_in · K² times the input’s element count.

im2col earns its keep at scale. For a 3×224×224 image with a 3×3 kernel and P=1, the output is 224×224 = 50,176 positions, each column is 3 × 3 × 3 = 27 numbers, and the whole cols matrix holds 27 × 50,176 = 1,354,752 floats — 5.4 MB in float32, exactly 9× the raw image (27/3). One image is fine. A batch of 64 is 1,354,752 × 64 × 4 bytes ≈ 331 MiB of extra memory for a single layer, which is why production kernels often refuse to materialize it: implicit GEMM (general matrix multiply), direct convolution, Winograd for 3×3, and FFT (Fast Fourier Transform) convolution for large kernels are all strategies for getting the matmul speed without the cols tax.

The other number worth carrying around is the work. Every output pixel needs C_in × K² multiply-accumulates per output channel, and there are H_out × W_out × C_out of them:

Where H_out · W_out · C_out · C_in · K² comes from

Every output pixel is produced once per output channel. Each of those values is a dot product between one C_in × K × K kernel volume and one C_in × K × K input window, which costs C_in · K² multiplies. Multiplying the counts:

MACs = H_out · W_out · C_out · C_in · K² = 224 · 224 · 64 · 3 · 9 = 50,176 · 64 · 27 = 86,704,128 ≈ 87 M multiply-accumulates ≈ 173 MFLOPs (a MAC counts as one multiply + one add) for scale: ResNet-50 ≈ 4 GFLOPs and ~25.6M parameters for a 224×224 input; MobileNetV2 ≈ 0.3 GFLOPs and ~3.5M parameters — the depthwise design trades a little accuracy for a 10× cheaper forward pass.
Quick check

For a 3-channel input and a 3×3 kernel, how many numbers does ONE im2col column hold — and how many columns does a 224×224 output produce?

STACKING AND RECEPTIVE FIELDS

Each layer adds a ring.
Depth buys sight.

One 3×3 kernel sees nine pixels. Stack two and a neuron in the second layer sees 5×5 of the original image; stack three and it sees 7×7. That arithmetic — not parameter count — is what makes deep convolution stacks powerful.

The receptive field of an activation is the patch of the original input that it depends on. A single 3×3 convolution has a 3×3 field. Put a second 3×3 convolution on top of it and the second layer’s pixels each see 3×3 of the first layer’s map — but a corner pixel of that map already depended on 3×3 of the input, so the total footprint is 5×5. Nothing new was added to the operator; depth composed the windows.

r = 1 + L · (K − 1) stride 1, K × K kernels, L layers L = 0 1×1 (the pixel itself) L = 1 3×3 L = 2 5×5 L = 3 7×7 ← three 3×3 layers reach a 7×7 field L = 4 9×9 L = 5 11×11 L = 6 13×13 with strides, the ring grows by the stride product seen so far: r = r + (K − 1) · stride_product; stride_product *= S

Two things follow from that table. First, you can build any receptive field you want out of 3×3 layers, one ring at a time. Second, the arithmetic is why VGG, ResNet and ConvNeXt all settled on “3×3 all the way down”: two 3×3 layers reach the same 5×5 field as one 5×5 layer with 18 weights per channel pair instead of 25 (28% fewer) and an extra nonlinearity in between — and three 3×3 layers reach 7×7 with 27 weights against 49 for a single 7×7 (45% fewer). For 64 input and 64 output channels the numbers are 2 × 64 · 64 · 9 = 73,728 against 64 · 64 · 25 = 102,400. Smaller and more expressive: an unusually one-sided trade.

Strides change the arithmetic but keep the idea. Each strided layer widens the ring by (K − 1) × the product of all strides before it, so a few downsamples let a ten-layer network cover the whole 224×224 image. Worked example with the source’s function: layers [3×3 s1, 3×3 s2, 3×3 s2] produce fields of 3, then 5, then 9 — the final layer’s ring of 2 is multiplied by the stride product 2 left behind by the stride-2 layer below it, so it adds 4 to the field instead of 2.

This is also the right place to pin down translation equivariance, the property the whole design exists to exploit. Convolution is equivariant: shift the input by (Δi, Δj) pixels and the output shifts by the same amount, because the same weights ran over the shifted window. It is not invariant — the output didn’t stay put, it moved with the input. Invariance comes later, from pooling the whole map down to a single vector for a classifier: that is what lets a network answer “is there a cat?” instead of “where is the cat?”. Two honest caveats: with stride S, exact shifts survive only for multiples of S, and near the borders, padding makes the equivalence approximate rather than exact.

Stack layers, watch the field grow

Add 3×3 layers one at a time — each one adds a ring of two pixels to what the final neuron can see. The nested outlines are the field after 1, 2, 3, … layers; the green cell is the output pixel doing the seeing.

layers L = 3 (of 6 for K=3) kernel K = 3 × 3 formula r = 1 + L(K − 1) = 1 + 3 × 2 = 7 → one output pixel depends on 7 × 7 = 49 input pixels per layer L1 r=3 · L2 r=5 · L3 r=7 parameter arithmetic (C_in = C_out = 64, bias included) one 3×3 layer 64·64·3² + 64 = 36,928 stack of 3 110,784 two 3×3 layers 18 weights per channel pair · reach a 5×5 field one 5×5 layer 25 weights per channel pair · same field, one nonlinearity for C=64: the pair spends 73,856 parameters; the single 5×5 would spend 102,464

The whole reason VGG, ResNet and ConvNeXt go “3×3 all the way down”: two 3×3 layers reach a 5×5 field with 18 weights per channel pair instead of 25 — and there is a nonlinearity in between.

Quick check

Two 3×3 convolutions are stacked, stride 1. What is the receptive field of the second layer — and its parameter count per channel pair compared with one 5×5 layer?

THE SAME CONV, SHIPPED

You understand it now.
Here is the one-liner.

PyTorch’s nn.Conv2d wraps the exact operation you just built, with autograd, CUDA kernels and cuDNN underneath. The shape semantics — and the size formula — are identical.

Everything in this lesson transfers directly. nn.Conv2d owns a weight tensor of shape (C_out, C_in, K, K) and one bias per output channel, initializes them the way Phase 3 taught, and runs the forward pass as a single fused kernel on the GPU. Change padding or stride and the output follows the formula from chapter 02 to the pixel.

The same layer, now in PyTorchpython
import torch
import torch.nn as nn

conv = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3,
                 stride=1, padding=1)
print(conv)
print(f"weight shape: {tuple(conv.weight.shape)}   # (C_out, C_in, K, K) = (64, 3, 3, 3)")
print(f"bias shape:   {tuple(conv.bias.shape)}     # one per output channel")
print(f"param count:  {sum(p.numel() for p in conv.parameters())}")   # 1792

x = torch.randn(8, 3, 224, 224)
y = conv(x)
print(f"input  {tuple(x.shape)} -> output {tuple(y.shape)}")
# (8, 3, 224, 224) -> (8, 64, 224, 224)   padding=1, stride=1: same size
# padding=0 -> (8, 64, 222, 222) · stride=2 -> (8, 64, 112, 112)
64 × 3 × 3 × 3 + 64 = 1,792 parameters, exactly the formula from chapter 04 — while a dense layer on the same 150,528-input image would need about 9.63 million.

Three production details are worth knowing before you meet them in a real architecture. First, strided convolutions replace pooling: a 3×3 stride-2 layer with P=1 takes 224×224 to 112×112 and learns how to downsample instead of throwing away the odd pixel. Second, depthwise separable convolution splits the job into a depthwise conv (one K×K kernel per channel,C · K² + C parameters) followed by a pointwise 1×1 conv that mixes channels (C_in · C_out + C_out). For C=64 and K=3 that is 640 + 4,160 = 4,800 parameters instead of 64 · 64 · 9 + 64 = 36,928 — about 7.7× fewer — which is how MobileNetV2 fits in a phone. Third, cross-correlation, not convolution: the mathematical convolution flips the kernel before sliding, and every deep-learning framework skips that flip because the weights are learned anyway. The sign conventions in chapter 03 are signs; the leaky abstraction is universal, so it is worth saying out loud once.

If you want to see the operation living inside a modern architecture, look at the patch embedding of the ViT (Vision Transformer): it is literally nn.Conv2d(3, 768, kernel_size=16, stride=16) — a convolution whose 16×16 kernels are the patches, with stride equal to the kernel size so they do not overlap. The transformer’s “patchifier” is a convolution.

the whole lesson in five lines K² weights per channel pair a 3×3 kernel = 9 numbers, shared everywhere H_out = ⌊(H − K + 2P)/S⌋ + 1 padding keeps size, stride divides it params = C_out·C_in·K² + C_out every output channel sees every input channel MACs = H_out·W_out·C_out·C_in·K² the work one image costs r = 1 + L(K − 1) depth grows the receptive field, one ring per layer
CHECK YOURSELF

Five questions.
Then the terms worth keeping.

Answer before you look. The output-size question and the parameter-count question are the two you will be asked to do in your head for the rest of the phase; the receptive-field question and the im2col question are the ones that separate a formula you have memorized from a mechanism you can reason with.

0 / 5 answered · 0 correct

01You feed a 224×224 RGB image to a conv with kernel_size=3, stride=1, padding=0. What is the output spatial size?

02A conv layer has in_channels=3, out_channels=64, kernel_size=3, with bias. How many learnable parameters?

03Why do modern CNNs prefer stacks of 3×3 convolutions over a single 5×5 or 7×7 conv?

04What does the im2col transformation actually do?

05You stack four 3×3 convolutions (all stride 1, no pooling). What is the receptive field of a neuron in the final layer?

Key terms, demystified

Click a card to swap the lazy description for what it actually means.

Exercises from the lesson

Three problems with exact numbers — walk a four-layer stack through the size and receptive-field arithmetic, add groups to the from-scratch convolution and count the depthwise savings, and derive the backward pass through im2col with a hand-checked 3×3 example. Try first; a worked answer is one click away.

  1. Easy — Given a 128×128 grayscale input and a stack of [Conv3x3(s=1,p=1), Conv3x3(s=2,p=1), Conv3x3(s=1,p=1), Conv3x3(s=2,p=1)], compute the output spatial size and the receptive field at every layer by hand. Verify with a PyTorch nn.Sequential of dummy convs.
    Show one worked answer

    Sizes come from floor((H − K + 2P)/S) + 1 with K=3 and P=1, so the span is H + 2 − 3 = H − 1. Layer 1 at stride 1 → floor(127/1) + 1 = 128; layer 2 at stride 2 → floor(127/2) + 1 = 63 + 1 = 64; layer 3 at stride 1 → floor(63/1) + 1 = 64; layer 4 at stride 2 → floor(63/2) + 1 = 31 + 1 = 32. Sizes: 128 → 128 → 64 → 64 → 32. Receptive fields use r = r + (K − 1) × stride_product, with stride_product starting at 1: layer 1 → 1 + 2×1 = 3, product stays 1; layer 2 → 3 + 2×1 = 5, product becomes 2; layer 3 → 5 + 2×2 = 9, product stays 2; layer 4 → 9 + 2×2 = 13, product becomes 4. Final receptive field 13×13 on a 128×128 input. Verification: build an nn.Sequential of four nn.Conv2d(1→8→8→8→8, kernel 3, the strides and paddings above), pass torch.randn(1, 1, 128, 128) through it layer by layer, and print x.shape[-2:] after each — it prints 128, 64, 64, 32. The pattern to notice: the two stride-2 layers did the downsampling, and each also doubled the ring every later layer contributes — depth and stride together decide how much of the image one neuron can see.

  2. Medium — Extend conv2d_naive and conv2d_im2col to accept a groups argument. Show that groups = C_in = C_out reproduces a depthwise convolution and that its parameter count is C·K² instead of C·C·K².
    Show one worked answer

    Implement groups by splitting the input channels into G contiguous slices, convolving each with its own slice of the weight tensor (shape (C_out, C_in/G, K, K)), and concatenating the outputs along the channel axis — exactly what nn.Conv2d(groups=G) does. The parameter count becomes C_out × (C_in/G) × K² + C_out biases. At G = C_in = C_out = C: C × 1 × K² + C = C·K² + C, a factor of C smaller than the standard C·C·K² + C. For C=64, K=3: depthwise is 576 + 64 = 640 parameters against 36,928 — 57.7× fewer. The catch is that each output channel now sees exactly one input channel, so there is no cross-channel mixing; that is why the depthwise separable block adds a pointwise 1×1 conv (C_in × C_out + C_out = 4,096 + 64 = 4,160 parameters). The combined 640 + 4,160 = 4,800 is still 7.7× smaller than the standard 36,928 and is the block MobileNetV2 repeats 17 times. Sanity check with torch: sum(p.numel() for p in nn.Conv2d(64, 64, 3, groups=64).parameters()) returns 640.

  3. Hard — Implement the backward pass of conv2d_im2col by hand: given the gradient of the output, compute the gradient of x and w, and verify against torch.autograd.grad on the same inputs and weights. The trick: the gradient of im2col is col2im, and it has to accumulate overlapping windows.
    Show one worked answer

    With Y = W_flat @ cols, the matrix-calculus answers are one line each: dW_flat = dY @ cols.T (shape (C_out, C_in·K²), reshaped back to (C_out, C_in, K, K)), db = dY summed over all H_out·W_out positions per channel, and dX = col2im(W_flat.T @ dY), where col2im scatters each column back into the patch it came from and adds — because neighbouring windows overlap, a pixel near the centre of the image contributes to up to K² outputs. A tiny worked example makes the accumulation visible. Let x be the 3×3 grid holding 1…9, let w be the 2×2 kernel [[1, 0], [0, −1]], stride 1, no padding, so Y is 2×2 and every entry equals −4. Take dY as all ones. Then dW[0][0] is the sum of the four top-left pixels it multiplied: 1 + 2 + 4 + 5 = 12; dW[0][1] = 2 + 3 + 5 + 6 = 16; dW[1][0] = 4 + 5 + 7 + 8 = 24; dW[1][1] = 5 + 6 + 8 + 9 = 28. For dX, each placement (i, j) adds w[a][b] to dX[i+a][j+b]: the centre pixel x[1][1] receives four contributions (−1 from the first placement, 0 and 0 from the two edge placements, +1 from the last) and cancels to 0, while dX = [[1, 1, 0], [1, 0, −1], [0, −1, −1]]. That cancellation is the point: col2im is an accumulation over overlapping windows, not a reshape. Verify with the two-line torch pattern — make x = arange(1, 10).reshape(1, 1, 3, 3) with requires_grad, w = tensor([[[[1.0, 0.0], [0.0, −1.0]]]]) with requires_grad, run y = torch.nn.functional.conv2d(x, w), call y.backward(torch.ones_like(y)), and print x.grad and w.grad. The numbers must match the hand computation to float32 precision; a mismatch usually means the scatter forgot to add instead of assign.

Terms this lesson borrows from later lessons (or outside)

You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.

  • image tensors (HWC vs CHW)What the kernel actually slides over: the (C, H, W) float tensor, its 0–255 versus 0–1 scale and its channels-first layout — the pipeline this lesson assumes and Phase 4, Lesson 01 builds.
  • backpropagationHow the nine shared weights are learned: every placement sends gradient to the same kernel, and the gradient with respect to the input is itself a convolution whose im2col form is col2im, which must accumulate overlapping windows. Phase 3, Lesson 03 derives the chain rule the update depends on.
  • activation functionConvolution is linear, so a stack of convs with no nonlinearity collapses into one big conv. The ReLU inserted between them is half the reason two 3×3 layers beat one 5×5. Phase 3, Lesson 04.
  • optimizerThe rule that turns those accumulated gradients into updates — and the reason parameter sharing is also a statistical statement: every location votes on the same weight. Phase 3, Lesson 06.
  • nn.Module / PyTorchnn.Conv2d is where this operation ships, with autograd and cuDNN underneath and the identical shape semantics. Phase 3, Lesson 11 introduces the module system it lives in.
KEEP GOING

A picture is a start.
Practice is the rest.

This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.

Lesson text adapted from AI Engineering from Scratch (Phase 04, Lesson 02) and the Math Foundations Notebook reference build. The five labs — the convolution playground with its draggable window and editable weights, the output-size calculator with floor-drop and even-kernel warnings, the im2col visualizer, the kernel gallery, and the receptive-field stepper — are original to this page, as are the dense-versus-conv arithmetic (150,528 inputs per neuron, 574 MiB for a 1,000-unit layer, the 5,376× ratio), the fully worked 5×5 → 3×3 source example with all nine outputs, the Sobel step-edge verification (Gx = 4, √18 ≈ 4.243) and the DC-gain rule behind the kernel table, the im2col memory and FLOP numbers (5.4 MB per image, ≈ 331 MiB per batch, 86,704,128 MACs), the stride-aware receptive-field walk, the equivariance-versus-invariance distinctions, the depthwise-separable parameter arithmetic, and the memory hook. Every number shown is computed live by the labs or verified by hand in the prose.