Skip to content

Intro to Convolutional Neural Networks (CNN) for Images

Every model so far flattened its input. Flattening a 28×28 image throws away the fact that pixel (5, 5) is next to pixel (5, 6) — and then asks the network to rediscover it from 784 unordered numbers. Convolution keeps that structure, and the saving is not subtle: on a 224×224 photo the first layer goes from 4,816,928 parameters to 896.

Every measurement on this page uses Fashion-MNIST — 28×28 greyscale clothing images, ten classes — because it is small enough to run every experiment on a CPU in seconds and real enough that the results mean something.

  • Convolution implemented in twelve lines and matched to tf.nn.conv2d at 2.38e−07.
  • What four hand-written kernels do to a real image, and why the kernel’s sum predicts its behaviour.
  • The parameter saving, counted: 78× on Fashion-MNIST, 5,376× on a 224×224 photo.
  • Receptive-field arithmetic: eight 3×3 layers see 17×17; add stride 2 and they see 511×511.
  • Output-shape arithmetic for valid and same padding at three strides, checked against Keras.
  • Equivariance verified: shifting the input shifts the feature map by exactly 0.00e+00 in the interior.

Convolution is a small weighted sum, repeated

Section titled “Convolution is a small weighted sum, repeated”

A kernel is a small grid of weights. Slide it across the image, and at each position write down the sum of (kernel weight × pixel underneath):

out[i,j]=u=0k1v=0k1K[u,v]in[i+u,  j+v]\text{out}[i, j] = \sum_{u=0}^{k-1} \sum_{v=0}^{k-1} K[u, v] \cdot \text{in}[i + u, \; j + v]
The whole operation, no framework
def convolve(image, kernel):
    size = kernel.shape[0]
    out = np.zeros((image.shape[0] - size + 1, image.shape[1] - size + 1))
    for row in range(out.shape[0]):
        for column in range(out.shape[1]):
            patch = image[row:row + size, column:column + size]
            out[row, column] = (patch * kernel).sum()
    return out

On a 28×28 input with a 3×3 kernel that gives a 26×26 output — 283+1=2628 - 3 + 1 = 26 — and it costs 9 multiply-adds per output pixel, 6,084 for the whole map. Checked against tf.nn.conv2d: maximum difference 2.38e−07, which is float32 rounding.

figure Four hand-written 3x3 kernels on one image matplotlib
Five greyscale panels. The first is a 28x28 Fashion-MNIST pullover. The next four show the same image after a vertical-edge kernel, which highlights the left and right sides of the garment; a horizontal-edge kernel, which highlights the shoulders and hem; a sharpen kernel, which crisps the outline; and a 3x3 mean blur, which softens everything. Five greyscale panels. The first is a 28x28 Fashion-MNIST pullover. The next four show the same image after a vertical-edge kernel, which highlights the left and right sides of the garment; a horizontal-edge kernel, which highlights the shoulders and hem; a sharpen kernel, which crisps the outline; and a 3x3 mean blur, which softens everything.
No training involved — these are four fixed grids of nine numbers each. The vertical-edge kernel responds to the garment's left and right boundaries and is near zero across the flat body; the horizontal one picks out the shoulders and hem instead. The two kernels whose weights sum to 1 preserve the image's overall brightness; the two that sum to 0 return zero wherever the input is flat.
KernelSum of weightsResponse rangeMean |response|
vertical edges0.00−4.0000 to 4.00000.7425
horizontal edges0.00−3.8627 to 3.87060.4515
sharpen1.00−1.8510 to 2.89410.8813
blur (3×3 mean)1.000.0000 to 0.96170.6557

The sum of the weights tells you what a kernel is for. Sum to zero and it measures change: a flat patch produces exactly zero, so the output is an edge map. Sum to one and it preserves brightness: the blur’s output range (0.0000–0.9617) is nearly the input’s (0.0000–1.0000), because it is an average.

A convnet learns these numbers instead of being given them — but the first layer of a trained convnet reliably ends up looking like this set.

What it buys: parameters that do not depend on the image size

Section titled “What it buys: parameters that do not depend on the image size”

A Dense layer connects every input to every unit. A Conv2D layer connects a k×kk \times k window to every unit, and reuses the same weights at every position.

Dense: nin×u+uConv2D: k2×cin×cout+cout\text{Dense: } n_{\text{in}} \times u + u \qquad \text{Conv2D: } k^2 \times c_{\text{in}} \times c_{\text{out}} + c_{\text{out}}
figure Same 32 outputs, two ways of connecting them matplotlib
Log-scale bar chart comparing Dense(32) against Conv2D(32, 3x3) for three input sizes. Dense grows from 25,120 to 98,336 to 4,816,928 parameters while Conv2D stays at 320, 896 and 896. Log-scale bar chart comparing Dense(32) against Conv2D(32, 3x3) for three input sizes. Dense grows from 25,120 to 98,336 to 4,816,928 parameters while Conv2D stays at 320, 896 and 896.
The Conv2D bars barely move: 320 parameters on a 28x28x1 input and 896 on a 224x224x3 one, because the count depends only on the kernel size and the channel counts. The Dense bars grow with the pixel count, reaching 4,816,928 on the photo — 5,376 times more for the same 32 outputs.
InputDense(32)Conv2D(32, 3×3)Ratio
Fashion-MNIST 28×28×125,12032078×
small colour 32×32×398,336896110×
photo 224×224×34,816,9288965,376×

Two properties fall out of weight sharing, and they are the reason convnets work on images:

  • The parameter count is independent of the input size. The same Conv2D(32, 3×3) layer handles 28×28 and 224×224 with 896 parameters, so a bigger image costs compute but not memory for weights.
  • A feature learned in one place works everywhere. A Dense layer would have to learn “vertical edge” separately for every position it might appear in.
diagram Diagram mermaid

Each filter produces one feature map — a 2-D image of “how strongly this pattern responded here”. A layer with 32 filters produces 32 of them, stacked into a depth axis, and the next layer’s kernels span that whole depth: a 3×3 kernel over 32 channels is 3×3×32=2883 \times 3 \times 32 = 288 weights per filter.

PaddingStrideFormulaOutput side
valid1(283+1)/1\lceil (28 - 3 + 1)/1 \rceil26
valid2(283+1)/2\lceil (28 - 3 + 1)/2 \rceil13
valid3(283+1)/3\lceil (28 - 3 + 1)/3 \rceil9
same128/1\lceil 28/1 \rceil28
same228/2\lceil 28/2 \rceil14
same328/3\lceil 28/3 \rceil10

Every row was checked against a real Conv2D layer. The trap is in the name: padding="same" does not mean “same output size” — it means “pad so the kernel can be centred on every input pixel”. With stride 1 that happens to preserve the size; with stride 2 it halves it.

Each layer’s output pixel depends on a small window of the layer below, which depends on a larger window below that. The recursion is

r=r1+(k1)j1,j=j1sr_{\ell} = r_{\ell-1} + (k - 1) \cdot j_{\ell-1}, \qquad j_{\ell} = j_{\ell-1} \cdot s

where rr is the receptive field, jj the jump, kk the kernel size and ss the stride.

figure How far back into the input each layer can see matplotlib
Log-scale plot of receptive field against layers stacked. The 3x3 stride-1 line grows linearly to 17 pixels at eight layers, the 5x5 line to 33, and the stride-2 line grows exponentially to 511. A dashed line marks the 28-pixel image size. Log-scale plot of receptive field against layers stacked. The 3x3 stride-1 line grows linearly to 17 pixels at eight layers, the 5x5 line to 33, and the stride-2 line grows exponentially to 511. A dashed line marks the 28-pixel image size.
With stride 1 the receptive field grows linearly — eight 3x3 layers reach 17 pixels, still less than a whole 28-pixel image. Introduce stride 2 and it grows geometrically, passing the image size by layer four and reaching 511 by layer eight. Downsampling is how convnets see globally without needing hundreds of layers.
Recipe1 layer248
3×3, stride 135917
5×5, stride 1591733
3×3, stride 23731511
7×7, stride 17132549

Read the first two rows together: two 3×3 layers see the same 5×5 window as one 5×5 layer, using 2×9=182 \times 9 = 18 weights per channel pair instead of 25 — and with a nonlinearity in between. That comparison is why almost every modern architecture is built from stacked 3×3 kernels.

Row three is why downsampling exists. Stride 1 alone would need dozens of layers before any unit could see a whole image.

Convolution commutes with translation: shift the input and the feature map shifts identically. Verified by shifting an image three pixels right and comparing against shifting the feature map three pixels right:

Region comparedmax |f(shift(x)) − shift(f(x))|
the whole map6.81e−01
the interior, excluding the border0.00e+00

The interior agreement is exact. The whole-map discrepancy is entirely a boundary effect — the shift wraps pixels around the edge while padding="same" pads with zeros, so the two disagree only in the border columns. Stating this carelessly is a common error: convolution is exactly equivariant away from the boundary, and approximately equivariant at it.

Equivariance is not invariance. The feature map still knows where the object was:

Operationmax difference after a 3-pixel shift
global max pooling1.07e−02
global average pooling2.16e−02

Both are near zero but not zero — again from the boundary. Pooling over the whole map is what converts “the pattern is here” into “the pattern is present”, which is what a classifier needs.

sketch Slide a kernel by hand p5.js
A 3x3 kernel over an 8x8 input. Click a kernel, drag the highlighted window, and read the multiply-add that produces the output pixel.
sketch The measured table, ranked p5.js
Click a column to rank every row by it. The bars are that column's values and the highest and lowest are computed from the numbers, not written in.
  • Reading padding="same" as “same output size”. With stride 2 it halves the side length; the name describes the padding, not the shape.
  • Forgetting that a kernel spans every input channel. A 3×3 kernel over 32 channels is 288 weights, not 9.
  • Stacking stride-1 layers and expecting global context. Eight of them see 17 pixels of a 28-pixel image. Downsample.
  • Claiming convolution is translation invariant. It is equivariant; pooling supplies the invariance.
  • Claiming equivariance is exact everywhere. Measured 0.00e+00 in the interior and 6.81e−01 at the boundary.
  • Choosing one 5×5 layer over two 3×3 layers. Same receptive field, 25 weights against 18, and one fewer nonlinearity.
  • Flattening straight after the last conv layer. That Dense layer will dominate the parameter count; global pooling usually works as well for far less.
  • Convolution is a small weighted sum slid across the input: 9 multiply-adds per output pixel, 6,084 for a 26×26 map, matched to TensorFlow at 2.38e−07.
  • A kernel’s weight sum predicts its behaviour: sum 0 measures change, sum 1 preserves brightness.
  • Weight sharing makes the parameter count independent of image size — 896 parameters on a 224×224×3 photo against 4,816,928 for Dense(32).
  • Output side is (nk+1)/s\lceil (n - k + 1)/s \rceil for valid and n/s\lceil n/s \rceil for same, both checked against Keras.
  • Receptive field grows linearly with stride 1 (17 pixels after eight 3×3 layers) and geometrically with stride 2 (511).
  • Two 3×3 layers match one 5×5 layer’s receptive field with 18 weights instead of 25, plus an extra nonlinearity.
  • Convolution is exactly equivariant to translation in the interior (0.00e+00) and approximately so at the boundary; global pooling converts that into invariance.

Downsampling is the other half of the architecture, and there is more than one way to do it: Pooling & CNN Architecture.

pch.quizTag pch.quizDefaultTitle
  1. A Conv2D(32, 3x3) layer has 896 parameters on a 224x224x3 input and 320 on a 28x28x1 one. Why does the image size barely matter?

    pch.quizShowAnswer

    B — Because the same kernel weights are reused at every position — the count is kernel area times input channels times filters, plus one bias per filter, with no dependence on the pixel count — Dense(32) on the same photo needs 4,816,928 parameters — 5,376 times more for the same 32 outputs.

  2. You use Conv2D(32, 3, strides=2, padding='same') on a 28x28 input. What is the output side length?

    pch.quizShowAnswer

    B — 14 — 'same' pads so the kernel can be centred everywhere, but the stride still divides the output, giving ceil(28/2) — The name refers to the padding, not the output shape. With stride 1 it happens to preserve the size; with stride 2 it halves it.

  3. Eight stacked 3x3 stride-1 layers have a receptive field of 17 pixels. What does that mean for a 28x28 image?

    pch.quizShowAnswer

    B — No unit can see the whole image yet, so a purely stride-1 stack needs many more layers — which is why downsampling exists, taking the field to 511 pixels by layer eight — With stride 2 the field grows geometrically and passes the image size by layer four.

  4. Shifting an image 3 pixels right changed the feature map by 6.81e-01 over the whole map but 0.00e+00 in the interior. What does that show?

    pch.quizShowAnswer

    B — Convolution is exactly equivariant away from the boundary; the whole-map difference comes only from the edge, where a wrap-around shift and zero padding disagree — Precision matters here: the exact claim is interior equivariance. Boundaries always break it slightly, and so do stride and pooling misalignment.

  5. Why do modern architectures prefer two stacked 3x3 layers over one 5x5 layer?

    pch.quizShowAnswer

    B — Because two 3x3 layers cover the same 5x5 receptive field using 18 weights per channel pair rather than 25, and add a nonlinearity in between — Same field, fewer weights, more nonlinearity. This is the observation VGG made and everything since has kept.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading