Module C · Lecture 13

Convolution, Padding, Stride & Pooling

The exact arithmetic behind every feature map a CNN ever produces — one sliding dot product, one output-size formula, and the padding, stride and pooling knobs that control it.

⏱ ~80 min 🧩 Builds on: Lecture 12 🎯 CO3
🧭 Why we're learning this now

Lecture 12 argued convolution is the fix for images, but waved its hands at the actual arithmetic. This lecture makes it precise — exactly how a filter slides, what padding and stride do to the output size, and how pooling shrinks a feature map — because every later CNN lecture (and Lecture 24's convolutional autoencoders) assumes you can compute these sizes without hesitating.

  • Define the convolution operation as a sliding elementwise multiply-and-sum, and derive the output-size formula.
  • Compute feature-map output sizes by hand for given kernel size, stride and padding, including cases that don't fit.
  • Explain why zero padding is used, and why odd-sized kernels are preferred for "same" padding.
  • Extend convolution to multi-channel inputs and count the parameters in a convolutional layer.
  • Distinguish stride (which reduces resolution) from dilation (which enlarges receptive field without doing so).
  • Perform max pooling and Sobel edge detection by hand on small worked examples.

1. The Convolution Operation

A convolutional layer works with a small learnable filter (or kernel) — typically 3×3 or 5×5 — that slides across the input. At every position, the filter and the underlying patch of the input are multiplied elementwise and summed into a single number: a dot product between the flattened filter and the flattened patch. The filter then moves by stride \(S\) pixels and repeats, producing one output value per position — together, these values form the output feature map.

Output-size formula

For a square input of size \(W\times W\), a square filter of size \(K\times K\), and stride \(S\):

$$W' = \frac{W-K}{S}+1$$

This must come out to a whole number — \((W-K)\) must be exactly divisible by \(S\), otherwise the filter cannot scan the whole image (Section 1.1 below).

Three worked examples, all with input \(H=W=7\):

Kernel \(K\)Stride \(S\)\((W-K)/S\)Output size
314/1 = 45×5
324/2 = 23×3
334/3 = 1.33…does not fit
⚠ Why stride 3 fails here

With \(W=7, K=3, S=3\): \(7-3=4\), and \(4/3\) is not an integer. Concretely, the filter's top-left corner can only legally sit at columns 0 and 3 (column 6 would push the filter past the edge of a 7-wide image) — after the window starting at column 3, the next jump of 3 lands at column 6, but a 3-wide filter starting at column 6 needs columns 6,7,8, and column 7 doesn't exist. The last column of the image is never scanned by any window. This is exactly why the formula insists \((W-K)/S\) be a whole number: it's a divisibility check on whether the filter can tile the image exactly.

2. Sliding the Filter: Interactive Walkthrough

To make the sliding mechanism concrete, here is a 3×3 Sobel \(G_x\) filter (introduced formally in Section 7) sliding over a small 5×5 image with a vertical edge — pixels 10 on the left, 80 on the right. With \(W=5, K=3, S=1\), the output-size formula gives \(W'=(5-3)/1+1=3\), so there are exactly 9 valid filter positions.

🔢 Two positions, worked by hand

Every row of the input is \([10,10,10,80,80]\) — a flat block of 10s on the left, a flat block of 80s on the right, and a vertical edge running between columns 2 and 3. At each filter position, multiply the highlighted 3×3 window elementwise by \(G_x=\begin{bmatrix}-1&0&1\\-2&0&2\\-1&0&1\end{bmatrix}\) and add up all nine products.

Position (r=0, c=0) — window = columns 0,1,2, entirely inside the flat left region (every entry is 10):

Row 0: \(10(-1)+10(0)+10(1)=-10+0+10=0\)
Row 1: \(10(-2)+10(0)+10(2)=-20+0+20=0\)
Row 2: \(10(-1)+10(0)+10(1)=-10+0+10=0\)

Output = \(0+0+0=\mathbf{0}\) — no gradient, because the window is perfectly flat.

Position (r=0, c=2) — window = columns 2,3,4, straddling the edge (10, 80, 80 in every row):

Row 0: \(10(-1)+80(0)+80(1)=-10+0+80=70\)
Row 1: \(10(-2)+80(0)+80(2)=-20+0+160=140\)
Row 2: \(10(-1)+80(0)+80(1)=-10+0+80=70\)

Output = \(70+140+70=\mathbf{280}\) — a large response, because the window straddles a sharp intensity change. This is the entire mechanism behind every convolution in this course: slide, multiply elementwise, sum, move on.

You can replay all 9 positions interactively below — useful for testing yourself before moving on:

Input (5×5)

Gx output (3×3)

The highlighted 3×3 window is the current receptive field; each step multiplies it elementwise by Gx and writes the sum into the matching output cell. Notice the output is 0 wherever the window sits inside a flat region, and jumps to 280 the moment the window straddles the edge — matching the two positions worked out by hand above.

3. Zero Padding

Every convolution without padding shrinks the feature map (the 7×7 → 5×5 example above). Stack enough layers like that and the feature map vanishes long before the network gets deep. Zero padding adds \(P\) rows/columns of zeros around the border of the input before convolving, which updates the output-size formula:

$$W' = \frac{W-K+2P}{S}+1$$
🔢 "Same" padding: preserving size

Input \(H=W=7\), \(K=3\), \(S=1\), \(P=1\):

$$W' = \frac{7-3+2(1)}{1}+1 = \frac{6}{1}+1 = 7$$

The output stays 7×7 — exactly the input size. This is called "same" padding.

In general, to preserve spatial size exactly with stride 1, you need:

$$P = \frac{K-1}{2}$$
✅ Why odd-sized filters (3×3, 5×5, …) are preferred

The formula \(P=(K-1)/2\) only gives a whole number when \(K\) is odd. Geometrically, an odd-sized filter has a single, well-defined center pixel — the input pixel directly below that center is exactly the pixel the output value "represents," with an equal number of neighboring pixels on every side (left/right, top/bottom). An even-sized filter (e.g. 2×2, 4×4) has no center pixel — its receptive field is asymmetric around any single output position, which complicates padding and interpretation. This geometric symmetry is why 3×3 and 5×5 filters dominate real architectures (recall VGG16's uniform 3×3 choice, Lecture 12).

4. Multi-Channel Convolution

Real images aren't single 2D grids — an RGB image has \(D=3\) channels. A filter for a \(D\)-channel input must itself be \(D\)-channel (e.g. a 3×3×3 filter for an RGB image). The convolution multiplies-and-sums across all channels simultaneously at each spatial position — so, importantly, one filter always produces one 2D output map, regardless of how many input channels it read from.

Depth in the output comes from using multiple filters. Apply \(D_K\) independent filters to the same input, and you get a \(D_K\)-channel output — each filter contributing exactly one of those channels.

Parameter count for a convolutional layer

Input \(H\times W\times D_I\), \(D_K\) filters of size \(K\times K\):

$$\text{weights} = K\times K\times D_I\times D_K \qquad \text{(} + D_K \text{ biases, one per filter)}$$

producing an output feature map of shape \(H'\times W'\times D_K\).

🔢 Worked example

Input \(28\times28\times3\). Apply a single 3×3 filter with 1×1 padding (so, by Section 3's "same" rule, spatial size is preserved):

$$28\times28\times3 \;\xrightarrow{\;1\ \text{filter, }3\times3,\ P{=}1\;}\; 28\times28\times1$$

Now apply 15 such filters instead of one:

$$28\times28\times3 \;\xrightarrow{\;15\ \text{filters}\;}\; 28\times28\times15$$

The number of filters directly and only controls the output channel depth — spatial size is governed purely by \(K, S, P\) as in Sections 1 and 3.

5. Stride & Dilation

Stride controls how far the filter jumps between positions. Stride 1 (the default) moves the filter one pixel at a time, producing a dense, high-resolution output. Stride 2 moves the filter two pixels at a time — this subsamples the image and roughly halves the output resolution in each dimension, directly reducing the amount of computation in every subsequent layer.

Dilation is a different knob entirely. With dilation factor \(l\), the filter samples every \(l\)-th pixel instead of a contiguous patch — the filter's weights don't change in count, but the pixels they touch spread out. A nominal 3×3 filter with dilation 2 skips every other pixel and so actually spans a 5×5 receptive field, while still only doing 9 multiply-adds.

Standard 3×3 (dilation 1)

Dilated 3×3, dilation 2 → spans 5×5

Both filters still have only 9 weights. The dilated filter (right) samples the highlighted cells only — every other pixel — so it "sees" a 5×5 area with a 3×3-sized filter.
✅ Stride vs. dilation — don't confuse them

Stride shrinks the output resolution as a side effect of subsampling. Dilation enlarges the receptive field without shrinking resolution and without adding parameters — the output size formula from Section 1 is unaffected by dilation on its own. This is the mechanism behind WaveNet-style dilated/causal convolutions, which stack layers of increasing dilation to cover long sequences with very few parameters.

6. Pooling

Pooling spatially down-samples a feature map: fewer values means fewer parameters downstream, less computation, and reduced risk of overfitting. The most common choice is max pooling — typically a 2×2 window with stride 2 — which keeps only the largest value in each window. Pooling uses the same output-size formula as convolution (Section 1), almost always with no padding, and has no learnable parameters at all — it's a fixed, deterministic operation.

🔢 Worked example: 2×2 max pooling, stride 2

Input 4×4:

$$\begin{bmatrix}1&3&2&0\\4&6&1&2\\0&1&3&5\\2&2&2&4\end{bmatrix}$$

Stride 2 with a 2×2 window means the four windows are non-overlapping and tile the input exactly — no pixel is read twice. Each output entry is simply the largest of its window's four values:

Top-left window (rows 0–1, cols 0–1) = \(\{1,3,4,6\}\): \(\max(1,3,4,6)=\mathbf{6}\)

Top-right window (rows 0–1, cols 2–3) = \(\{2,0,1,2\}\): \(\max(2,0,1,2)=\mathbf{2}\)

Bottom-left window (rows 2–3, cols 0–1) = \(\{0,1,2,2\}\): \(\max(0,1,2,2)=\mathbf{2}\)

Bottom-right window (rows 2–3, cols 2–3) = \(\{3,5,2,4\}\): \(\max(3,5,2,4)=\mathbf{5}\)

Assembling these four numbers in the same row-major layout as the windows gives the output:

$$\xrightarrow{\;2\times2\ \max,\ S{=}2\;}\; \begin{bmatrix}6&2\\2&5\end{bmatrix}$$

Replay the same four windows interactively below — useful for testing yourself before moving on:

Input (4×4)

Max-pooled output (2×2)

Step through all four non-overlapping 2×2 windows. Top-left block max = 6, top-right = 2, bottom-left = 2, bottom-right = 5 — matching the hand computation above.

7. Sobel Edge Detection: Convolution in Action

Convolution isn't only for learned CNN filters — the classic, hand-designed Sobel operator is a concrete, motivating example of what a single well-chosen 3×3 filter can compute: image gradients, and from them, edges. Two fixed kernels estimate the gradient in the horizontal (\(x\)) and vertical (\(y\)) directions:

$$G_x=\begin{bmatrix}-1&0&1\\-2&0&2\\-1&0&1\end{bmatrix} \qquad G_y=\begin{bmatrix}-1&-2&-1\\0&0&0\\1&2&1\end{bmatrix}$$

Convolving a local neighborhood \(N\) with each kernel gives two numbers, \(S_x = N * G_x\) and \(S_y = N * G_y\), which combine into a gradient magnitude and direction:

$$|\nabla I|=\sqrt{S_x^2+S_y^2} \qquad \theta=\arctan2(S_y, S_x)$$

A large \(|\nabla I|\) means pixel intensity is changing sharply at that location — an edge. A small (near-zero) magnitude means the neighborhood is roughly flat.

🔢 Worked numerical example

Neighborhood \(N=\begin{bmatrix}10&10&10\\10&50&80\\10&50&80\end{bmatrix}\) — a region with a bright block in its bottom-right corner.

Sx = N ⊙ Gx, summed

Multiply every entry of \(N\) by the entry in the matching position of \(G_x\), then add up all nine products, one row at a time:

Row 0: \(10(-1)+10(0)+10(1)=-10+0+10=0\)

Row 1: \(10(-2)+50(0)+80(2)=-20+0+160=140\)

Row 2: \(10(-1)+50(0)+80(1)=-10+0+80=70\)

$$S_x = 0+140+70 = \mathbf{210}$$

Sy = N ⊙ Gy, summed

Same neighborhood \(N\), now elementwise against \(G_y=\begin{bmatrix}-1&-2&-1\\0&0&0\\1&2&1\end{bmatrix}\):

Row 0: \(10(-1)+10(-2)+10(-1)=-10-20-10=-40\)

Row 1: \(10(0)+50(0)+80(0)=0+0+0=0\)

Row 2: \(10(1)+50(2)+80(1)=10+100+80=190\)

$$S_y = -40+0+190 = \mathbf{150}$$

✅ Combine into magnitude and direction

$$|\nabla I|=\sqrt{S_x^2+S_y^2}=\sqrt{210^2+150^2}=\sqrt{44100+22500}=\sqrt{66600}\approx\mathbf{258.1}$$

(The course slide rounds this to \(\approx258.8\) — a small rounding difference from the source material; either way, this is a large gradient magnitude.) Direction: \(\theta=\arctan2(S_y,S_x)=\arctan2(150,210)\approx35.5°\).

A large \(|\nabla I|\) means pixel intensity is changing sharply at this location → a strong edge is detected at this neighborhood.

Prefer to click through the same computation interactively, one row at a time? Same numbers, same order — useful for testing yourself before moving on:

8. Common Pitfalls

⚠ Arithmetic mistakes that silently produce wrong feature-map shapes
  • Forgetting to check divisibility. Always confirm \((W-K+2P)/S\) is a whole number before trusting an output shape — frameworks like Keras/PyTorch will either error out or silently floor/crop, and floor-based cropping quietly discards the last row/column of the input.
  • Confusing stride with dilation. Both are integer "spacing" parameters, but stride changes output resolution; dilation changes receptive field size while output resolution (for the convolution itself) stays governed by the same formula.
  • Assuming pooling has learnable weights. It doesn't — max pooling is a fixed operation. Only the convolutional filters are trained.
  • Forgetting a filter's channel depth must match its input. A filter for a 3-channel input is 3-channel too — a "3×3 filter" over RGB really has 27 weights (3×3×3), not 9.
  • Using an even-sized kernel and expecting clean "same" padding. Section 3 showed \(P=(K-1)/2\) only lands on an integer for odd \(K\).

9. Summary

Key takeaways
  • Convolution is a sliding elementwise multiply-and-sum; output size is \(W'=(W-K)/S+1\), or \(W'=(W-K+2P)/S+1\) with padding — and this must be an integer.
  • Zero padding (typically \(P=(K-1)/2\)) preserves spatial size, and only works cleanly for odd-sized filters, which is why 3×3/5×5 kernels dominate.
  • A filter always spans the full input channel depth and produces a single 2D output map; stacking \(D_K\) filters produces a \(D_K\)-channel output. Parameter count: \(K\times K\times D_I\times D_K + D_K\).
  • Stride subsamples and reduces resolution; dilation enlarges the receptive field for free, without touching parameter count or resolution.
  • Max pooling (typically 2×2, stride 2) down-samples with zero learnable parameters, using the same output-size formula as convolution.
  • The Sobel operator is hand-designed convolution in action: \(S_x=210, S_y=150, |\nabla I|\approx258.8\) on the worked neighborhood — a strong, unambiguous edge.

10. Code: Convolution Arithmetic, Pooling & Sobel

This NumPy script reproduces every worked number above: the output-size formula and its three K/S test cases, the "same"-padding check, the multi-channel shape/parameter formula, the exact 4×4 max-pooling example, and Sobel \(S_x, S_y\) on the worked neighborhood.

lecture-13-convolution-pooling.py
"""
Lecture 13 -- Convolution, Padding, Stride & Pooling
Reproduces: the output-size formula (with/without padding), the
multi-channel conv parameter-count formula, the exact 4x4 max-pooling
worked example, and Sobel Sx/Sy/|grad I| on the worked neighborhood.
"""
import numpy as np

# ---- 1. Output-size formula: W' = (W - K + 2P)/S + 1 ----
def output_size(W, K, S, P=0):
    num = W - K + 2 * P
    if num % S != 0:
        return None  # cannot scan the whole image
    return num // S + 1

for K, S in [(3, 1), (3, 2), (3, 3)]:
    out = output_size(7, K, S)
    print(f"W=7, K={K}, S={S}, P=0 -> {out if out else 'INVALID (does not fit)'}")
print(f"W=7, K=3, S=1, P=1 (same padding) -> {output_size(7, 3, 1, 1)}\n")

# ---- 2. Multi-channel convolution: output shape + parameter count ----
def conv_output(H, W, D_in, K, S, P, D_k):
    Hp, Wp = output_size(H, K, S, P), output_size(W, K, S, P)
    params = (K * K * D_in * D_k) + D_k
    return (Hp, Wp, D_k), params

shape, params = conv_output(28, 28, 3, K=3, S=1, P=1, D_k=15)
print(f"28x28x3 input, 3x3 filter, P=1, 15 filters -> {shape}, {params} params\n")

# ---- 3. Max pooling: exact 4x4 worked example ----
def max_pool(mat, size=2, stride=2):
    mat = np.array(mat)
    out_h = (mat.shape[0] - size)//stride + 1
    out_w = (mat.shape[1] - size)//stride + 1
    out = np.zeros((out_h, out_w))
    for r in range(out_h):
        for c in range(out_w):
            out[r, c] = mat[r*stride:r*stride+size, c*stride:c*stride+size].max()
    return out

X = [[1, 3, 2, 0], [4, 6, 1, 2], [0, 1, 3, 5], [2, 2, 2, 4]]
pooled = max_pool(X)
print("Max pooling 4x4 -> 2x2:\n", pooled)
assert np.array_equal(pooled, [[6, 2], [2, 5]]), "does not match worked example!"

# ---- 4. Sobel edge detection ----
def conv2d_valid(img, kernel):
    img, kernel = np.array(img, float), np.array(kernel, float)
    kh, kw = kernel.shape
    H, W = img.shape
    out = np.zeros((H - kh + 1, W - kw + 1))
    for r in range(out.shape[0]):
        for c in range(out.shape[1]):
            out[r, c] = np.sum(img[r:r+kh, c:c+kw] * kernel)
    return out

Gx = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]
Gy = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]]
N = [[10, 10, 10], [10, 50, 80], [10, 50, 80]]

Sx = conv2d_valid(N, Gx)[0, 0]
Sy = conv2d_valid(N, Gy)[0, 0]
mag = np.sqrt(Sx**2 + Sy**2)
theta = np.degrees(np.arctan2(Sy, Sx))
print(f"\nSobel on N: Sx={Sx:.0f}, Sy={Sy:.0f}, |grad I|={mag:.2f}, theta={theta:.1f} deg")
print("(Course slide rounds |grad I| to ~258.8.)")

# Slide Gx across a 5x5 vertical-edge image (matches the interactive demo)
img5 = [[10, 10, 10, 80, 80]] * 5
print("\nSobel-Gx response over the 5x5 vertical-edge image (3x3 output):\n",
      conv2d_valid(img5, Gx))

⬇ Download lecture-13-convolution-pooling.py   More resources for this lecture →