AI Knowledge Tree

Convolution and pooling

Build the 2D convolution operation from sliding inner products, then add stride, padding, and pooling.

⏱ 30 minPrerequisites:Multi-layer perceptron

Images have local structure and translation invariance: a cat edge looks the same wherever it appears in the image, and pixels matter relative to their neighbors. A fully connected MLP throws this away by mixing every input with every output. Convolution exploits both: the same small filter is applied at every location, drastically cutting parameters and bringing the inductive bias of "local + translation- equivariant" into the network[1, 2].

The 2D convolution

Given an input feature map X∈RH×W×CinX \in \mathbb{R}^{H \times W \times C_{\text{in}}} and a filter K∈Rkh×kw×Cin×CoutK \in \mathbb{R}^{k_h \times k_w \times C_{\text{in}} \times C_{\text{out}}}, the output at position (i,j,c)(i, j, c) is

Yi,j,c=∑u=0kh−1∑v=0kw−1∑c′=0Cin−1Xi+u, j+v, c′⋅Ku,v,c′,c+bc.Y_{i, j, c} = \sum_{u=0}^{k_h - 1}\sum_{v=0}^{k_w - 1}\sum_{c'=0}^{C_{\text{in}} - 1} X_{i+u,\, j+v,\, c'} \cdot K_{u, v, c', c} + b_c.

Three pieces: a spatial window of size kh×kwk_h \times k_w at each output location, a per-channel inner product with the filter, and a bias per output channel.

The filter KK has khkwCinCoutk_h k_w C_{\text{in}} C_{\text{out}} weights, shared across every spatial location. For a 256×256256 \times 256 image with 64 output channels and 3×33 \times 3 filters, the layer has 3⋅3⋅Cin⋅643 \cdot 3 \cdot C_{\text{in}} \cdot 64 weights regardless of image size, where a fully connected equivalent would have 256⋅256⋅Cin⋅64256 \cdot 256 \cdot C_{\text{in}} \cdot 64.

Stride and padding

Stride ss moves the window by ss positions instead of 1, downsampling the output. Output spatial size becomes ⌊(H−kh)/s⌋+1\lfloor (H - k_h)/s \rfloor + 1.

Padding adds zeros around the input border. "Same" padding chooses padding so the output has the same spatial size as the input (for stride 1); "valid" padding uses none.

These two knobs control the receptive field and the spatial dimensions through the network.

Receptive field

Each output unit depends on a small patch of the input through one convolution. Stacking LL convolutions of size kk each gives a receptive field of size 1+L(k−1)1 + L(k - 1) on the input. Strided convolutions and pooling expand the receptive field much faster (multiplicatively).

By design, deep CNNs grow their receptive field from local edges to whole-image features as you go up the layer stack: early layers see 3-pixel patches, deep layers see hundreds of pixels.

Why convolution and not full mixing

Three inductive biases for free:

  • Locality: the output at position (i,j)(i, j) depends only on a small patch around (i,j)(i, j).
  • Translation equivariance: shifting the input shifts the output by the same amount. A cat is a cat wherever it appears.
  • Parameter sharing: the same filter is used at every location, cutting parameters by orders of magnitude.

For natural images these biases are correct, and a CNN with one tenth of an MLP's parameters typically far outperforms it. For other domains (tabular data), the locality assumption is wrong and convolutions are inappropriate.

Pooling

After convolution, pooling downsamples the spatial dimensions while keeping the channel dimension:

  • Max pooling: take the max within each window. Preserves the strongest activation, robust to small shifts.
  • Average pooling: take the mean within each window. Smoother.
  • Global average pooling: average over the entire spatial extent, collapsing H×W×CH \times W \times C to CC. Used at the end of a CNN before the classifier head.

Modern architectures sometimes drop pooling in favor of strided convolutions (the convolution itself does the downsampling). They are nearly equivalent.

Padding details

  • Causal padding (1D): pad only on the past side. Used in WaveNet and time-series CNNs.
  • Reflection / replication padding: instead of zero. Reduces edge artifacts in image generation networks.
  • Symmetric vs asymmetric: filter sizes are typically odd (3,5,73, 5, 7) so the same padding on each side works cleanly.

Channels

The channel axis is the truly important one: it carries the learned features. A 3×3×64→3×3×1283 \times 3 \times 64 \to 3 \times 3 \times 128 layer mixes 128 output features from 64 input features at every location.

CNNs commonly:

  • Start with few channels (e.g. 64) matching the image's 3 RGB inputs.
  • Double channels every time spatial dimensions halve, keeping the feature volume roughly constant.
  • End with many channels (256-2048) feeding into a small spatial extent that gets globally pooled into a vector for the classifier.
A 3×3 vertical-edge filter (left) responds only at the square's vertical edges. Stacking convolutions grows the receptive field linearly with depth (right), so deep stacks integrate wide context.
A 3×3 vertical-edge filter (left) responds only at the square's vertical edges. Stacking convolutions grows the receptive field linearly with depth (right), so deep stacks integrate wide context.

Check it on arrays

python · numpy
import numpy as np

rng = np.random.default_rng(0)

def conv2d(X, K, stride=1, pad=0):
    """Naive 2D convolution. X: (H, W, Cin). K: (kh, kw, Cin, Cout)."""
    if pad > 0:
        X = np.pad(X, ((pad, pad), (pad, pad), (0, 0)))
    H, W, Cin = X.shape
    kh, kw, _, Cout = K.shape
    out_h = (H - kh) // stride + 1
    out_w = (W - kw) // stride + 1
    Y = np.zeros((out_h, out_w, Cout))
    for i in range(out_h):
        for j in range(out_w):
            patch = X[i*stride:i*stride+kh, j*stride:j*stride+kw, :]   # (kh, kw, Cin)
            Y[i, j, :] = np.tensordot(patch, K, axes=([0, 1, 2], [0, 1, 2]))
    return Y

# Single-channel input, edge-detection filter
X = np.zeros((10, 10, 1))
X[3:7, 3:7, 0] = 1.0                                # white square on black

# Vertical-edge filter
K_vert = np.array([[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]]).reshape(3, 3, 1, 1).astype(float)
Y = conv2d(X, K_vert)
print(f"input shape  {X.shape},  output shape {Y.shape}")
print("vertical-edge response (rounded):")
print(Y[:, :, 0].astype(int))

# Stride and padding effects on output spatial size
for stride in [1, 2]:
    for pad in [0, 1]:
        out = conv2d(X, K_vert, stride=stride, pad=pad)
        print(f"stride={stride}, pad={pad}: output {out.shape[:2]}")

# Max pooling
def max_pool2d(X, k=2, stride=2):
    H, W, C = X.shape
    out_h = (H - k) // stride + 1
    out_w = (W - k) // stride + 1
    Y = np.zeros((out_h, out_w, C))
    for i in range(out_h):
        for j in range(out_w):
            patch = X[i*stride:i*stride+k, j*stride:j*stride+k, :]
            Y[i, j, :] = patch.max(axis=(0, 1))
    return Y

pooled = max_pool2d(X)
print(f"\nmax pool 2x2 stride 2: {X.shape} -> {pooled.shape}")

The vertical-edge filter lights up at the left and right sides of the white square (positive and negative responses respectively); pooling halves the spatial dimensions.

Exercises

Exercise 1. An input feature map is 32×3232 \times 32. A convolution uses a 5×55 \times 5 filter with stride 22 and padding 11. Compute the output spatial size by hand.

Solution

Step 1. With padding pp added on each side, the padded input has spatial size 32+2⋅1=3432 + 2 \cdot 1 = 34 along each axis.

Step 2. The output size along one axis is ⌊H+2p−khs⌋+1\left\lfloor \frac{H + 2p - k_h}{s} \right\rfloor + 1. Substituting H=32H = 32, p=1p = 1, kh=5k_h = 5, s=2s = 2:

⌊32+2−52⌋+1=⌊292⌋+1=14+1=15.\left\lfloor \frac{32 + 2 - 5}{2} \right\rfloor + 1 = \left\lfloor \frac{29}{2} \right\rfloor + 1 = 14 + 1 = 15.

The output is 15×1515 \times 15.

Exercise 2. A network stacks LL convolutions, each k×kk \times k with stride 11 and no pooling. Derive the receptive field size 1+L(k−1)1 + L(k - 1), then find how many 3×33 \times 3 layers are needed for one output unit to see a 15×1515 \times 15 region of the input.

Solution

Step 1. One k×kk \times k convolution makes each output unit depend on kk input positions along an axis, a receptive field of width k=1+1⋅(k−1)k = 1 + 1 \cdot (k - 1).

Step 2. Let rℓr_\ell be the receptive field width after ℓ\ell layers, so r1=kr_1 = k. Adding a k×kk \times k layer on top means each of its kk inputs already covers rℓr_\ell positions, and consecutive inputs are offset by one (stride 11), so the union covers rℓ+1=rℓ+(k−1)r_{\ell+1} = r_\ell + (k - 1). Starting from r1=1+(k−1)r_1 = 1 + (k - 1) and adding k−1k - 1 for each of the remaining L−1L - 1 layers gives

rL=1+L(k−1).r_L = 1 + L(k - 1).

Step 3. Set 1+L(3−1)=151 + L(3 - 1) = 15, so 2L=142L = 14 and L=7L = 7. Seven stacked 3×33 \times 3 convolutions give a 15×1515 \times 15 receptive field.

Exercise 3 (code). Implement the output-size formula and verify it against the conv2d from the lesson: for a 7×77 \times 7 single-channel input with a 3×33 \times 3 filter at stride 22, padding 11, the formula and the actual output shape must agree.

Solution
python · numpy
import numpy as np

def conv2d(X, K, stride=1, pad=0):
    if pad > 0:
        X = np.pad(X, ((pad, pad), (pad, pad), (0, 0)))
    H, W, Cin = X.shape
    kh, kw, _, Cout = K.shape
    out_h = (H - kh) // stride + 1
    out_w = (W - kw) // stride + 1
    Y = np.zeros((out_h, out_w, Cout))
    for i in range(out_h):
        for j in range(out_w):
            patch = X[i*stride:i*stride+kh, j*stride:j*stride+kw, :]
            Y[i, j, :] = np.tensordot(patch, K, axes=([0, 1, 2], [0, 1, 2]))
    return Y

def out_size(H, k, stride, pad):
    return (H + 2 * pad - k) // stride + 1

rng = np.random.default_rng(0)
X = rng.normal(size=(7, 7, 1))
K = rng.normal(size=(3, 3, 1, 1))

predicted = out_size(7, 3, stride=2, pad=1)          # (7 + 2 - 3)//2 + 1 = 4
actual = conv2d(X, K, stride=2, pad=1).shape[0]
assert predicted == actual == 4
print("predicted:", predicted, " actual:", actual)

The formula predicts a 4×44 \times 4 output and the explicit convolution agrees.

The next lesson stacks these primitives into the classic CNN architectures.


References

  1. [1]LeCun, Y., Bottou, L., Bengio, Y., & Haffner, P. (1998). "Gradient-based learning applied to document recognition". Proceedings of the IEEE, 86(11), 2278-2324. doi:10.1109/5.726791. Introduces the convolution-pooling stack with shared filters.↩
  2. [2]Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning, §9 (Convolutional Networks). MIT Press. Locality, parameter sharing, and equivariance as the convolutional inductive biases.↩