← All posts

Foundations

Post 7 of 15

Convolutions: sliding filters over images and audio

Posts in this series · 15

In the first Foundations post every neuron was connected to every input. That works for a handful of numbers. It falls apart the moment the input is a photo or a second of sound, because those inputs have thousands of numbers and a very particular structure: what matters is usually local (an edge, a click, a vowel onset), and the same kind of thing can appear anywhere.

A convolution is the layer built for exactly that structure. It takes one small set of weights, called a kernel or filter, and slides it along the input, computing the same weighted sum at every position. This post works through it slowly, by hand, from a six-sample signal up to the multi-channel layers inside the speech codec Mimi.

The whole model at a glance

Here is a whole convolutional neural network (CNN), the kind of network built from the layers in this post, classifying a tiny image. A 6 × 6 picture goes through two 3 × 3 filters, a ReLU (the “negative becomes zero” activation from post 1), max pooling, a flatten step and a dense layer, and comes out as two probabilities: “vertical line” or “horizontal line”. The filters are set by hand so every number is readable; a trained network learns its own. Each layer gets its own section below.

A tiny convolutional network classifying a 6 by 6 image as a vertical or horizontal line
Input image
Figure 1. A CNN from input to output. Shapes go 6 × 6 × 1 → 4 × 4 × 2 → 2 × 2 × 2 → 8 → 2. Try “Same bar, moved”: the answer does not change, because the filters slide and pooling keeps only the strongest response. The plus sign has both lines and gets 50/50.

Why a fully connected layer is wasteful

A fully connected layer (also called a dense or linear layer) gives every output its own weight for every input. The number of weights is

weights=nin×nout\text{weights} = n_{\text{in}} \times n_{\text{out}}

Everyday picture: a school where every teacher must write a personal letter to every student. Double the students and the letters double too, for every teacher.

An image. A small greyscale photo of 100 by 100 pixels is 10,000 inputs. A first layer with 1,000 outputs needs 10,000×1,000=10{,}000 \times 1{,}000 = 10,000,000 weights.

A sound. One second of audio at 24 kHz (kilohertz, thousand samples per second; see audio basics) is 24,000 numbers. A layer that maps it to another 24,000 numbers needs 24,000×24,000=24{,}000 \times 24{,}000 = 576,000,000 weights. For one layer, for one second.

Worse than the size is the waste. A weight that has learned “a sudden jump between neighbouring samples means a click” at position 100 is no help at position 5,000. The layer has to learn the same idea again, separately, at every position.

A convolution with a kernel of 3 weights uses those 3 weights (plus one bias) everywhere. What changes if the input doubles in length? Nothing about the weight count: still 3.

A one-dimensional convolution, by hand

In one sentence: lay the kernel over the first few inputs, multiply the pairs that line up, add them, then slide the kernel one step and repeat.

y[n]=∑j=0k−1w[j] x[n+j]y[n] = \sum_{j=0}^{k-1} w[j]\, x[n + j]

A bias bb is usually added to every output as well, exactly as in post 1. We leave it at 0 here to keep the arithmetic short.

Everyday picture: reading a line of text through a cardboard window three letters wide. You slide the window one letter at a time and write down what you see each time.

Worked example. Input x=(1,2,0,3,1,2)x = (1, 2, 0, 3, 1, 2), kernel w=(1,0,−1)w = (1, 0, -1), so k=3k = 3.

Six inputs, a window of three, so there are 4 places the window fits: 6−3+1=46 - 3 + 1 = 4. If x[3]x[3] changed from 3 to 5, only the outputs whose windows cover position 3 would change: y[1]y[1] becomes −3-3 and y[3]y[3] becomes 33. Every output only “sees” its own small window.

A three-number kernel sliding along a six-number signal, one output per position
Input x (click a cell to change it)
Kernel w
Mode
Figure 2. One kernel, sliding. Press Step to move the kernel one position. The three numbers being multiplied light up, and the arithmetic is written out under the row. Click any input or weight to change it; every output updates. The toggle shows the flipped, textbook convolution.

A note on the name: it is really cross-correlation

In mathematics and signal processing, a convolution flips the kernel before sliding it: y[n]=∑jw[j] x[n−j]y[n] = \sum_j w[j]\, x[n - j]. What deep learning libraries compute, the formula above without the flip, is called cross-correlation. Everybody still calls it convolution.

With our numbers, the true flipped convolution gives (−1,1,1,−1)(-1, 1, 1, -1): the same values with the sign swapped, because flipping (1,0,−1)(1, 0, -1) gives (−1,0,1)(-1, 0, 1). Does it matter for learning? No. The kernel is learned, so a network that needs the flipped kernel simply learns it. It only matters when you copy a kernel from a signal-processing textbook into a network, or the other way round.

Kernels are pattern detectors

The weights decide what the kernel responds to. Take a toy signal that is flat, jumps up, stays up, then drops:

x=(0,0,0,3,3,3,0,0)x = (0, 0, 0, 3, 3, 3, 0, 0)

Edge detector w=(−1,0,1)w = (-1, 0, 1). Each output is “right neighbour minus left neighbour”, y[n]=x[n+2]−x[n]y[n] = x[n+2] - x[n]:

Output: (0,3,3,0,−3,−3)(0, 3, 3, 0, -3, -3). It is zero where the signal is flat, positive at the rising edge and negative at the falling edge. The kernel has turned “where does something change?” into numbers.

Smoother w=(13,13,13)w = (\tfrac13, \tfrac13, \tfrac13). Each output is the average of three neighbours:

Output: (0,1,2,3,2,1)(0, 1, 2, 3, 2, 1). The sharp cliff became a gentle ramp. This is a moving average, the same idea as the averages in the optimisers post, but over neighbouring positions instead of over time steps.

A trained network does not get hand-picked kernels. It starts from random ones and backpropagation shapes them into whatever detectors reduce the loss. The first layer of an image network typically ends up with edge-like and colour-blob kernels, and Krizhevsky and colleagues showed exactly such learned first-layer filters for AlexNet (2012).

A step signal and what different kernels turn it into
Kernel
Figure 3. Same signal, different kernels. Pick a preset or type your own three taps. The output bars and the arithmetic for the highlighted position update live.

Padding: valid and same

Our six inputs gave only four outputs, and the eight-sample step signal gave six. The window cannot hang off the end, so each layer shrinks the signal by k−1k - 1. Stack ten such layers and the signal wastes away.

Padding adds extra values, usually zeros, to both ends before sliding. Two common choices:

Worked example on the step signal with the edge kernel and one zero each side: the padded input is (0,0,0,0,3,3,3,0,0,0)(0, 0, 0, 0, 3, 3, 3, 0, 0, 0), ten numbers, giving eight outputs:

(0,0,3,3,0,−3,−3,0)(0, 0, 3, 3, 0, -3, -3, 0)

Eight in, eight out. The two new outputs at the ends only see a zero and a real value. Zeros are a guess about what lies beyond the edge, which is why outputs right at the border are slightly less trustworthy.

Stride, dilation and the output length

Two more knobs change where the kernel lands.

Stride ss is how far the kernel jumps between outputs. Stride 1 visits every position; stride 2 visits every second one, so the output is about half as long. That is downsampling: fewer numbers, each summarising a wider stretch. The Mimi codec inside Pocket TTS uses strides 4, 5 and 6, then 16, to squeeze 24,000 samples per second down to 12.5 frames per second; the Mimi post works through one strided layer in full.

Example: the smoother with one zero of padding each side and stride 2 on the step signal. It keeps every second output of the stride-1 version: (0,1,3,1)(0, 1, 3, 1).

Dilation dd spreads the kernel’s taps apart, leaving d−1d - 1 skipped inputs between them. With d=2d = 2 the kernel (−1,0,1)(-1, 0, 1) reads x[n]x[n], x[n+2]x[n+2], x[n+4]x[n+4]. It covers a span of 5 with only 3 weights. Example on the step signal, no padding: y[n]=x[n+4]−x[n]y[n] = x[n+4] - x[n] gives (3,3,0,−3)(3, 3, 0, -3).

All of these fit in one formula for the output length:

Lout=⌊L+2p−d(k−1)−1s⌋+1L_{\text{out}} = \left\lfloor \frac{L + 2p - d(k - 1) - 1}{s} \right\rfloor + 1

In words: take the padded length, subtract the kernel’s span, count how many strides fit, and add 1 for the starting position.

Worked example: L=10L = 10, k=3k = 3, p=1p = 1, s=2s = 2, d=2d = 2. The span is 2×2+1=52 \times 2 + 1 = 5. Then (10+2−4−1)/2=7/2=3.5(10 + 2 - 4 - 1)/2 = 7/2 = 3.5, rounded down to 3, plus 1 gives 4 outputs. Check with the earlier cases: valid on the step signal is (8−2−1)/1+1=6(8 - 2 - 1)/1 + 1 = 6; same is (8+2−2−1)/1+1=8(8 + 2 - 2 - 1)/1 + 1 = 8. What if LL were 11 instead? (11+2−5)/2=4(11 + 2 - 5)/2 = 4, plus 1 is 5.

Dumoulin and Visin’s “A guide to convolution arithmetic for deep learning” (2016) draws every one of these cases, and is worth keeping open.

Which inputs each output reads, for chosen padding, stride and dilation
Figure 4. Padding, stride and dilation. Move the sliders and watch which inputs each output reads. The formula fills in your numbers and must agree with the count of output cells.

Two dimensions: images

An image is a grid, so the kernel becomes a small grid too, and it slides both across and down. In one sentence: lay the 3 by 3 kernel on a 3 by 3 patch, multiply the nine pairs, add them, then slide.

y[r,c]=∑i=0k−1∑j=0k−1w[i,j] x[r+i, c+j]y[r, c] = \sum_{i=0}^{k-1} \sum_{j=0}^{k-1} w[i, j]\, x[r + i,\, c + j]

Take a 5 by 5 toy image of a dark area on the left and a bright area on the right (0 dark, 9 bright), every row the same:

x=(0009900099000990009900099),w=(−101−101−101)x = \begin{pmatrix} 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 \end{pmatrix}, \qquad w = \begin{pmatrix} -1&0&1 \\ -1&0&1 \\ -1&0&1 \end{pmatrix}

This kernel is a vertical edge detector: in each of its three rows it computes “right minus left”, then adds the rows.

Every row of the image is identical, so all three output rows are the same: a 3 by 3 output of (0,27,27)(0, 27, 27) in each row. Large values mark where the edge is. The size follows the formula in each direction: 5−3+1=35 - 3 + 1 = 3.

Now turn the kernel on its side, so each column computes “bottom minus top” (a horizontal edge detector). Every column of the image is constant from top to bottom, so every output is 0: there is no horizontal edge to find. Same image, different kernel, different answer.

A three by three window walking over a five by five image, filling a three by three output
Kernel
Image
Figure 5. A 3 by 3 window walking over a 5 by 5 image. Step moves the window one cell, left to right then down. Choose a preset kernel (vertical edge, horizontal edge, blur), and click pixels to switch them between dark and bright.

Channels: many inputs, many outputs

Real inputs have several channels: a colour image has red, green and blue planes; a hidden layer might have 64 feature maps. A convolution layer handles them like this:

  1. Each output channel has its own kernel for every input channel.
  2. It slides each of those kernels over its input channel, then adds the results together across input channels, plus one bias.
  3. Different output channels have different kernels, so each learns to detect a different pattern.
y[o,n]=bo+∑c=1Cin∑j=0k−1w[o,c,j] x[c,n+j]y[o, n] = b_o + \sum_{c=1}^{C_{\text{in}}} \sum_{j=0}^{k-1} w[o, c, j]\, x[c, n + j]

The Mimi post reads the same formula, with stride added, symbol by symbol for a real layer. Here is a toy you can check in your head. Two input channels of length 3, kernel size 2, two output channels:

Output channel 1:

Output channel 2:

Counting parameters. Each output channel has Cin×kC_{\text{in}} \times k weights (for 2-D, Cin×k×kC_{\text{in}} \times k \times k) plus one bias:

params=Cout×Cin×kh×kw+Cout\text{params} = C_{\text{out}} \times C_{\text{in}} \times k_h \times k_w + C_{\text{out}}

A 2-D layer from an RGB image (Cin=3C_{\text{in}} = 3) to 16 channels with 3 by 3 kernels: 16×3×9+16=432+16=16 \times 3 \times 9 + 16 = 432 + 16 = 448 parameters, whatever the image size. A 1-D audio layer from 64 to 128 channels with k=3k = 3: 128×64×3+128=128 \times 64 \times 3 + 128 = 24,704. Compare the ten million of the fully connected layer at the top.

PyTorch agrees. sum(p.numel() for p in layer.parameters()) adds up the kernel weights and the biases:

import torch.nn as nn

count = lambda m: sum(p.numel() for p in m.parameters())
print(count(nn.Conv1d(2, 2, kernel_size=2)))     # the toy above
print(count(nn.Conv2d(3, 16, kernel_size=3)))    # RGB to 16
print(count(nn.Conv1d(64, 128, kernel_size=3)))  # audio layer

It prints:

10
448
24704

The toy: 2×2×2=82 \times 2 \times 2 = 8 kernel weights plus 2 biases, 10.

Two input channels each convolved with their own kernel and summed into each output channel
Figure 6. Channels are summed, outputs are separate. Each step shows the per-channel partial sums before they are added, so you can see where the “across channels” sum happens.

Weight sharing and translation equivariance

Using the same kernel at every position is called weight sharing. It buys two things. The first is the parameter saving we just counted. The second is subtler: translation equivariance. “Translation” means shifting; “equivariance” means the output changes in the same way as the input. In one sentence: if you shift the input, the output shifts by the same amount and is otherwise unchanged.

Take the step signal and shift it right by one position: (0,0,0,0,3,3,3,0)(0, 0, 0, 0, 3, 3, 3, 0). Run the edge kernel (−1,0,1)(-1, 0, 1):

The same pattern, moved right by one. The detector did not need to relearn anything: a cat in the corner of a photo or a click late in a recording triggers the same kernel. (Values near the borders can differ, because of what falls off the end or into padding. Stride also breaks exact equivariance: with stride 2, a shift of 1 is not a whole output step.)

Equivariance is not invariance. An invariant output would not change at all when the input shifts. Classifiers often want invariance (“cat” anywhere is still “cat”), and they get closer to it by adding pooling, described below.

A signal shifted right and its convolution output shifting right with it
Stride
Figure 7. Shift the input, the output follows. Drag the shift slider. A ghost copy of the unshifted output stays behind so you can compare.

Receptive field: how much each output sees

One output of a k=3k = 3 layer sees 3 inputs. Stack a second k=3k = 3 layer on top: each of its outputs sees 3 first-layer outputs, which together cover 5 inputs. The span of input that can affect one output is its receptive field.

rℓ=rℓ−1+(kℓ−1)×Jℓ−1,Jℓ=Jℓ−1×sℓr_\ell = r_{\ell-1} + (k_\ell - 1) \times J_{\ell-1}, \qquad J_\ell = J_{\ell-1} \times s_\ell

In words: each new layer adds k−1k - 1 extra neighbours, and each neighbour is as far apart as the jump so far.

Three layers, k=3k = 3, stride 1. Jump stays 1. r1=1+2=3r_1 = 1 + 2 = 3, r2=3+2=5r_2 = 3 + 2 = 5, r3=5+2=r_3 = 5 + 2 = 7. Growth is slow: 2 per layer.

Three layers, k=3k = 3, stride 2. r1=1+2×1=3r_1 = 1 + 2 \times 1 = 3 (jump becomes 2), r2=3+2×2=7r_2 = 3 + 2 \times 2 = 7 (jump becomes 4), r3=7+2×4=r_3 = 7 + 2 \times 4 = 15. Striding doubles the growth each layer.

Dilation does the same trick without throwing away resolution: a dilated kernel of size 3 with dilation dd behaves like a kernel of span 2d+12d + 1. WaveNet (van den Oord and colleagues, 2016) doubled the dilation every layer, 1, 2, 4, up to 512, so a stack of ten layers reached 1,024 samples back while every layer kept the full sample rate. The paper gives each 1-to-512 block a receptive field of 1,024 samples.

A stack of layers with the inputs one top output depends on lit up
Layers
Figure 8. The receptive field grows with depth. Choose stride 1, stride 2 or dilation 1, 2, 4, then Step to add one layer at a time; the gold cells are the inputs one output depends on.

Pooling: the quick summary

A pooling layer is a window with no weights: it summarises each window with a fixed rule. Max pooling keeps the largest value; average pooling keeps the mean. It is usually used with stride equal to the window, to shrink the signal.

Example: (1,3,2,5,4,0)(1, 3, 2, 5, 4, 0), window 2, stride 2.

Max pooling says “was the pattern present anywhere in this window?” and ignores exactly where, which buys a little shift invariance. LeCun and colleagues’ LeNet-5 (1998) alternated convolutions with subsampling layers of this kind. Many modern networks replace pooling with strided convolutions, which learn their own way to summarise; Mimi downsamples only with strided convolutions.

Causal convolution: audio and streaming

The convolutions so far look at neighbours on both sides. For live audio that is a problem: output at time nn would need samples from the future, which have not arrived yet.

A causal convolution only looks backwards. You get it by putting all the padding on the left: pad k−1k - 1 zeros before the signal (or k−sk - s with stride ss), none after. Then output nn depends only on inputs at nn and earlier.

Example: kernel (1,0,−1)(1, 0, -1) on x=(1,2,0,3)x = (1, 2, 0, 3) with two zeros on the left. The padded input is (0,0,1,2,0,3)(0, 0, 1, 2, 0, 3), and the outputs are (0−1,0−2,1−0,2−3)=(−1,−2,1,−1)(0 - 1, 0 - 2, 1 - 0, 2 - 3) = (-1, -2, 1, -1). The output for time 0 used only x[0]x[0]; the output for time 3 used x[1]x[1] to x[3]x[3]. Four in, four out, nothing from the future.

This is how WaveNet generated audio one sample at a time, and how Mimi and SoundStream encode speech as it arrives. Rather than rebuild it here, see the causal padding section of the Mimi post, and the causal strided convolution figure in the SoundStream review, which shows each output reading exactly two strides of past input.

Transposed convolution: going back up

Encoders shrink; decoders must grow back. A transposed convolution (sometimes wrongly called “deconvolution”) runs the strided picture in reverse: each input value stamps a scaled copy of the kernel into the output, the stamps are placed ss apart, and overlapping stamps add.

Example: input (1,2)(1, 2), kernel (1,1,1)(1, 1, 1), stride 2. The first input stamps (1,1,1)(1, 1, 1) at positions 0 to 2; the second stamps (2,2,2)(2, 2, 2) at positions 2 to 4. Position 2 gets both: 1+2=31 + 2 = 3. Output: (1, 1, 3, 2, 2), five numbers from two. The length is (L−1)×s+k=1×2+3=5(L - 1) \times s + k = 1 \times 2 + 3 = 5.

The uneven bump at position 2 is why kernels that are not a multiple of the stride can leave a checkerboard pattern. Mimi’s decoder uses kernel = 2 × stride to keep overlaps even; the Mimi post and its strided-convolution figure show the full layer.

Backpropagation through a convolution

Training works exactly as in post 1: the loss sends back a gradient for every output, and we need the gradient for every weight. The only new twist is that each weight is used at every position, so its gradient adds up contributions from all of them.

In one sentence: the gradient for kernel weight jj is the input slid against the output gradients, which is itself a cross-correlation.

∂L∂w[j]=∑ng[n] x[n+j]\frac{\partial L}{\partial w[j]} = \sum_{n} g[n]\, x[n + j]

Why: y[n]y[n] contains the term w[j] x[n+j]w[j]\, x[n+j], so nudging w[j]w[j] moves y[n]y[n] by x[n+j]x[n+j] times the nudge, and the chain rule multiplies that by g[n]g[n]. Sum over every output the weight helped make.

Worked example. Input x=(1,2,3,4)x = (1, 2, 3, 4), kernel w=(1,0,−1)w = (1, 0, -1). Forward: y[0]=1−3=−2y[0] = 1 - 3 = -2, y[1]=2−4=−2y[1] = 2 - 4 = -2. Suppose the loss sends back g=(1,2)g = (1, 2).

That is the 2-tap “kernel” g=(1,2)g = (1, 2) slid along xx: a cross-correlation of input and output gradient. The bias gradient is just g[0]+g[1]=3g[0] + g[1] = 3.

The gradient for the input (needed to keep backpropagating to earlier layers) runs the other way: each g[n]g[n] stamps a copy of the kernel back onto the inputs it came from, exactly like the transposed convolution above. Here: g[0]g[0] stamps (1,0,−1)(1, 0, -1) at positions 0 to 2, g[1]=2g[1] = 2 stamps (2,0,−2)(2, 0, -2) at positions 1 to 3, giving ∂L/∂x=(1,2,−1,−2)\partial L / \partial x = (1, 2, -1, -2). That is why the transposed convolution has its name: it is the backward pass of an ordinary one.

With a learning rate of 0.1, plain gradient descent from post 2 would move the kernel to (1−0.5,0−0.8,−1−1.1)=(0.5,−0.8,−2.1)(1 - 0.5, 0 - 0.8, -1 - 1.1) = (0.5, -0.8, -2.1).

The output gradient sliding along the input to give the kernel gradient, then stamping back to give the input gradient
Figure 9. The kernel gradient is a sliding sum too. Step through the three weight gradients, the bias and the update, then watch the output gradients stamp the kernel back to give the input gradient.

A short history

Fukushima’s Neocognitron (1980) stacked layers of local feature detectors with shared structure and pooling-like stages, inspired by the visual cortex, but trained them without backpropagation. LeCun and colleagues (1989) trained a network of shared-weight local kernels end to end with backpropagation to read handwritten US zip codes, and the 1998 paper on gradient-based learning for document recognition presented LeNet-5. Krizhevsky, Sutskever and Hinton’s AlexNet (2012) scaled the same recipe up on GPUs (graphics processing units) and won the ImageNet image-classification challenge by a wide margin, which started the deep learning boom. WaveNet (2016) carried dilated causal convolutions to raw audio, and the speech codecs on this site still build on that lineage.

What to remember

Try it yourself

Sources