A 150 million weight layer or 1,792 weights? Learn why convolution is the right tool for images.
Intro
150 Million Weights vs 1,792
Take one ordinary photo and connect it to just one thousand neurons in a dense layer. You need about one hundred fifty million weights. Now do the same job with a convolution and you can get by with around eighteen hundred. This lesson shows exactly how that trick works, number by number.
- Dense layer on one photo
- Convolution: tiny shared kernel
Where We Are
In lesson 50 we built training loops in PyTorch, moved them to the GPU, and learned to debug them. Today we open the box on the layer that made vision work, the convolution. We will do it by hand, derive the size formulas, count parameters, and then write it from scratch in NumPy and check it against PyTorch.
- Lesson 50: loops, GPUs, debugging
- Today: convolution mechanics
Images and Why Dense Layers Fail
Images Are Tensors
A grayscale image is just a grid of numbers, each between zero and two hundred fifty-five. A colour image stacks three such grids, for red, green and blue. So we describe it as a tensor with shape height, width, channels. A 224 by 224 colour photo has shape 224, 224, 3.
- Shape (H, W, C)
- Pixels 0 to 255
- RGB has C = 3
Counting Dense Parameters
To feed that photo to a dense layer, we flatten it into a vector of 224 times 224 times 3, which is 150,528 numbers. With just one thousand hidden neurons, every neuron needs a weight for every input. That is 150,528,000 weights, plus a thousand biases. One layer, one small photo, and already over a hundred and fifty million parameters.
- 224 x 224 x 3 = 150,528 inputs
- 1,000 neurons
- About 150.5 million weights
Two Deeper Problems
The cost is only part of the problem. Flattening throws away the fact that neighbouring pixels belong together. And a cat in the top left uses completely different weights than a cat in the bottom right, so the network must relearn the same pattern at every position. We want a layer that looks locally and reuses what it learns everywhere.
- Flattening destroys neighbourhoods
- No translation sharing
Convolution by Hand
1D Convolution First
Start in one dimension. Our signal is one, two, three, four, five, and our kernel is one, zero, minus one. Slide the kernel along, multiply overlapping entries, and add. At the first position we get one times one, plus two times zero, plus three times minus one, which is minus two. Every position gives minus two, so the output is minus two, minus two, minus two.
- Signal [1, 2, 3, 4, 5]
- Kernel [1, 0, -1]
- Slide, multiply, sum
Convolution vs Cross-Correlation
Strictly, mathematical convolution flips the kernel before sliding it. Without the flip, the operation is called cross-correlation. Flip our kernel to minus one, zero, one, and every output becomes plus two instead of minus two. Deep learning libraries skip the flip and still call it convolution, because the kernel is learned anyway, so the network just learns the flipped version.
- True convolution flips the kernel
- Deep learning skips the flip
- Library 'conv' is cross-correlation
The 5 by 5 Image
Now two dimensions. Our five by five image has two dark columns of zeros on the left and three bright columns of nines on the right, so there is a vertical edge between column one and column two. The kernel is three by three with minus one in the left column, zeros in the middle, and plus one in the right column. It responds when the right side is brighter than the left.
- Dark left, bright right
- Kernel: columns -1, 0, 1
- Detects vertical edges
Computing the First Output
Place the kernel on the top left patch, columns zero to two. In each row we compute zero times minus one, zero times zero, plus nine times one, which is nine. There are three rows, so the total is twenty-seven. That is our first output value, and it is large because the patch contains the edge.
- Top-left 3x3 patch
- Three rows: 0 + 0 + 9 = 9
- Total = 27
Finishing the Feature Map
Slide one column right. Each row now gives zero times minus one, nine times zero, nine times one, again nine, so the total is twenty-seven. Slide once more and each row is minus nine, plus zero, plus nine, which is zero, because the patch is uniformly bright. Repeating for all three rows, the output is a three by three map where every row reads twenty-seven, twenty-seven, zero.
- Shift right: 27
- Shift again: 0
- Output is 3x3: every row 27, 27, 0
Why Weight Sharing Wins
Notice what happened. The same nine weights were used at every position, and each output looked only at a small neighbourhood. If the edge moved one pixel, the response would move one pixel too. This property is called translation equivariance, and it is why a handful of shared weights can replace millions.
- Same 9 weights at every position
- Local connections
- Translation equivariance
Stride, Padding and Dilation
Output Size Without Padding
Why was our output only three by three? A three wide kernel fits into a five wide image at three positions, which is five minus three plus one. Every unpadded convolution shrinks the image by k minus one. Stack many layers and the picture quickly vanishes, and pixels at the border get used far less than those at the centre.
- n = 5, k = 3
- Positions: 5 - 3 + 1 = 3
- Image shrinks each layer
Padding
Padding fixes this by adding a frame of zeros around the image. With a three by three kernel, padding of one keeps a 32 by 32 image at 32 by 32, so we call it same padding. In general, for odd kernels and stride one, the same padding is k minus one, divided by two. Zero padding is the default, and reflecting or replicating border values are alternatives.
- Add p zeros around the border
- 'Same' padding: p = (k-1)/2
- k = 3 means p = 1
Stride
Stride is how far the kernel jumps between positions. With stride one it visits every location. With stride two it skips every other one, so the output has roughly half the height and width, and a quarter of the values. This is one way networks reduce resolution while going deeper.
- Jump s pixels per step
- Stride 2 halves resolution
- Cheaper, coarser output
The Output-Size Formula
All of this combines into one formula. The output size is the floor of n plus two p minus k, divided by s, then plus one. Here n is the input size, p the padding on each side, k the kernel size and s the stride. The floor throws away a final partial step that does not fit.
- floor((n + 2p - k) / s) + 1
- n input, k kernel, p padding, s stride
- Floor drops partial steps
Checking the Formula
Check it on three cases. First, n of 32, kernel three, padding one, stride one: 32 plus 2 minus 3 is 31, divided by one is 31, plus one gives 32. Second, n of 7, kernel 3, no padding, stride 2: 4 divided by 2 is 2, plus one gives 3. Third, n of 224, kernel 7, padding 3, stride 2: 223 divided by 2 is 111.5, floor to 111, plus one gives 112.
- 32, k=3, p=1, s=1 gives 32
- 7, k=3, p=0, s=2 gives 3
- 224, k=7, p=3, s=2 gives 112
Dilation
Dilation spreads the kernel taps apart by inserting gaps. A three wide kernel with dilation two covers five pixels, because the effective size is d times k minus one, plus one. It sees a wider area with the same three weights per row. Just swap k for the effective size in the output formula. For n of 11, dilation 2 gives effective size 5, and the output is 7.
- Gaps between kernel taps
- Effective size: d(k-1) + 1
- Bigger view, same 9 weights
Multi-Channel Convolution
Kernels Have Depth
Real inputs have channels, so a kernel must too. A filter for an RGB image is three by three by three. At each position we multiply all twenty-seven entries with the patch beneath, add them up, and get one single number. So one filter produces one output channel, called a feature map.
- Kernel shape k x k x C_in
- Sum over all channels
- One filter gives one map
Many Filters, Many Feature Maps
A layer uses many filters, each learning to detect a different pattern. Sixty-four filters yield sixty-four feature maps, stacked into the next tensor. The parameter count is k times k times input channels times output channels, plus one bias per filter. This is where the learning lives.
- C_out filters
- Stack maps into new tensor
- Params: k*k*C_in*C_out + C_out
Parameter Counting Example
First layer: three by three kernel, three input channels, sixty-four filters. That is 3 times 3 times 3 times 64, which is 1,728 weights, plus 64 biases, giving 1,792. Another layer with 16 inputs and 32 outputs has 3 times 3 times 16 times 32, which is 4,608, plus 32 biases, equal to 4,640. Notice the count does not depend on image height or width at all, which is the answer to our hook.
- 3x3, 3 in, 64 out: 1,792
- 3x3, 16 in, 32 out: 4,640
- Independent of image size
Receptive Field and 1x1 Convolutions
1x1 Convolutions
A one by one convolution looks at a single pixel but all its channels. It is a small dense layer applied independently at every location. It changes the number of channels without touching the spatial size. Going from 256 channels to 64 costs 256 times 64 plus 64 biases, which is 16,448 parameters, and it is a cheap way to compress or mix features.
- Mixes channels, not space
- Dense layer at every pixel
- 256 to 64: 16,448 params
Receptive Field
The receptive field of a unit is the region of the original image that can influence it. One three by three layer sees three pixels across. Stack a second and each unit sees a three by three block of units that each saw three, so five across. A third gives seven. Depth lets small kernels build a large view.
- Input region affecting one output
- One 3x3 layer: 3
- Two: 5. Three: 7
Receptive Field Formula with Stride
Strides make the field grow faster. Each layer adds k minus one times the current jump, the pixel spacing between neighbouring units, and the jump multiplies by that layer's stride. Take a three by three conv, a two by two pool with stride two, then another three by three conv. The field goes to 3, then 4, then 4 plus 2 times 2, which is 8.
- r_out = r_in + (k-1) * jump
- jump multiplies by stride
- Conv3, pool2, conv3: r = 8
Why Stack Small Kernels
Three stacked three by three layers see the same seven by seven region as one big seven by seven kernel. But with C channels in and out, they cost 27 C squared weights versus 49 C squared. With 64 channels, that is 110,592 against 200,704. We also get three nonlinearities instead of one, so deeper stacks are both cheaper and more expressive.
- Three 3x3 equal one 7x7 view
- 27C^2 vs 49C^2 weights
- C = 64: 110,592 vs 200,704
Pooling
Max Pooling by Hand
Pooling shrinks feature maps with no learned weights. Take a four by four map with rows one, three, two, one; four, six, five, zero; seven, two, nine, eight; one, zero, three, four. Max pooling with a two by two window and stride two keeps the largest value in each block: six, five, seven, nine. The output is two by two.
- 2x2 window, stride 2
- Keep the largest value
- Output [6, 5; 7, 9]
Average and Global Pooling
Average pooling takes the mean of each block instead. On the same map we get 3.5, 2, 2.5 and 6. Global average pooling goes all the way, averaging each whole channel into one number, so a seven by seven by 512 tensor becomes a vector of 512. That replaces a huge flatten and dense step, and it works for any input size.
- Average: 3.5, 2, 2.5, 6
- Global average: one number per channel
- 7x7x512 becomes 512
What Pooling Buys You
Pooling gives a little tolerance to small shifts, since a feature moving one pixel inside a block gives the same maximum. It also cuts the cost of later layers by a factor of four for each two by two pool. The price is lost spatial detail. Modern designs sometimes replace pooling with strided convolutions, which learn how to downsample.
- Small shifts barely change output
- Less compute downstream
- Info is discarded
Backprop Through Convolution
Gradients, Summarized
Backpropagation through a convolution reuses the same operation. The gradient for the kernel is the cross-correlation of the input with the upstream gradient, because each weight touched every position, so contributions add up. The gradient for the input is a full convolution of the upstream gradient with the flipped kernel. The bias gradient is just the sum of the upstream gradient over positions.
- Bias gradient: sum of upstream
- Kernel gradient: correlate input with upstream
- Input gradient: full conv with flipped kernel
Pooling Gradients and Shared Weights
Pooling has no weights, but gradients still flow through it. For max pooling, the whole gradient goes to the position that won the max, and the others get zero. For average pooling, the gradient is split equally, so each of four entries gets a quarter. Autograd in PyTorch handles all of this for you, as we saw in lesson 49.
- Max pool: gradient to the argmax
- Average pool: split evenly
- Shared weights accumulate
Conv2d From Scratch in NumPy
Designing the Function
Now let's code it. Our function takes an input of shape batch, input channels, height, width, weights of shape output channels, input channels, k, k, a bias, a stride and a padding. First we pad with zeros using NumPy. Then we compute the output size with our formula, and allocate an array of that shape filled with zeros.
- Input (N, C_in, H, W)
- Weights (C_out, C_in, k, k)
- Loops over output position
The Core Loop
The core is a set of nested loops over each example, each filter, and each output row and column. For each one, we slice out the patch of the padded input, across all input channels, multiply it elementwise with that filter, sum, and add the bias. The patch start is the output index times the stride. It is slow, but every line maps directly to what we did by hand.
- Loop over batch, filters, rows, columns
- Slice the patch
- Sum of patch * kernel + bias
Testing on Our 5x5 Example
First test: feed in our five by five image and edge kernel with no padding. We should get the three by three map with rows twenty-seven, twenty-seven, zero, and we do. Then compare against PyTorch's functional conv2d on random tensors, with several strides and paddings, and check the maximum absolute difference. It should be around one in a million, just floating point rounding, so use allclose, not exact equality.
- Expect rows 27, 27, 0
- Compare with torch.nn.functional.conv2d
- Differences near 1e-6
Common Mistakes
Watch for three classic bugs. First, flipping the kernel when your library does not. Second, mixing up channel order: PyTorch expects channels first, while many image libraries give channels last. Third, off-by-one errors in output size, so always compare your shapes against the formula before comparing values. Most of these show up as a shape error long before any wrong number.
- Forgetting the flip is not needed
- Mixing up (H,W,C) and (C,H,W)
- Off-by-one in output size
Recap and Next Lesson
Recap
Let's recap. Images are tensors, and dense layers on them explode in parameters, while a convolution slides a small shared kernel and gets translation equivariance. The output size is the floor of n plus two p minus k over s, plus one, and the parameter count is k squared times input channels times output channels plus biases. One by one convolutions mix channels, depth grows the receptive field, pooling downsamples, and backprop reuses convolution itself.
- Local, shared weights beat dense
- Output: floor((n+2p-k)/s)+1
- Params: k*k*C_in*C_out + C_out
- Pooling downsamples; gradients reuse conv
Next: CNN Architectures
You now understand every moving part inside a convolutional layer. In lesson 52 we assemble these parts into full CNN architectures, learn how transfer learning reuses pretrained networks, and visualize what the filters actually detect.
- Lesson 52: architectures
- Transfer learning
- Visualizing features