Where we left off
In the previous article we established that an image is just a tensor: a grid of numbers with a height, a width and a number of channels. A grayscale photo is a single 2D grid of intensities; a colour photo is three stacked grids, one each for red, green and blue. That representation is the starting point for everything in computer vision, but on its own it does not tell a network how to find a cat's ear or a crack in a road. This article is about the operation that does the finding: convolution, together with the three knobs that shape it, padding, stride and pooling.
By the end you should be able to take any convolutional layer's settings, kernel size, padding and stride, and work out by hand what shape its output will have, how many parameters it uses, and roughly how much of the original image a single output value actually depends on. That last idea, called the receptive field, is one of the most useful mental tools you will carry into the rest of this path.
Why not just connect every pixel to every neuron
Before convolution became the default tool, the obvious approach was to flatten an image into a long vector and feed it to a fully connected layer, exactly as you would for tabular data. Flattening throws away the 2D structure, but it is worth seeing why that approach breaks down in practice, because it motivates everything that follows.
Take a modest 224 by 224 colour image, the kind of size used by many classic image classifiers. Flattened, that is 224 times 224 times 3, which is 150,528 numbers. If you connect that vector to a fully connected layer with just 1,000 hidden units, the weight matrix alone has 150,528 times 1,000, over 150 million parameters, for a single layer, before you have done anything clever. Two problems follow from this. First, that many parameters needs a very large amount of data and compute to train without overfitting. Second, and more subtly, a fully connected layer has no notion of locality: a weight connecting pixel (0,0) to a hidden unit is learned completely independently from the weight connecting pixel (1,1) to that same hidden unit, even though in a photograph, nearby pixels are usually related and a pattern that matters in one corner of the image, say a diagonal edge, often matters in exactly the same way in another corner. A fully connected layer has to relearn that pattern separately for every location, which is wasteful both in parameters and in the amount of data needed to learn it everywhere it appears.
Convolution fixes both problems at once by using two ideas: local connectivity, where each output value only looks at a small neighbourhood of the input, and weight sharing, where the same small set of weights is reused at every location in the image. Those two ideas together are the entire conceptual content of a convolutional layer. Everything else in this article is detail on how exactly the sliding and the sharing work.
The sliding window intuition
Picture a small window, say 3 pixels by 3 pixels, placed over the top-left corner of an image. Inside that window there are nine numbers from the image and nine learned weights, one for each position in the window. Multiply each image value by the weight in the same position, add up all nine products, and you get a single number. That number becomes one value in the output. Now slide the window one pixel to the right and repeat: multiply, sum, get another number. Keep sliding across the whole image, left to right and top to bottom, and you build up a full grid of output numbers, one for every position the window visited.
That grid of weights is called a filter or a kernel, and the grid of output numbers is called a feature map. The key point is that the same nine weights are used at every single position, which is the weight sharing mentioned above. If those nine weights happen to form a pattern that responds strongly to a vertical edge, then the feature map will have high values wherever a vertical edge occurs anywhere in the image, regardless of whether that edge is near the top-left or the bottom-right. That property, responding to a pattern wherever it appears, is what gives convolutional layers a useful degree of translation invariance.
Convolution with actual numbers
Let us compute one by hand. Take a small 5 by 5 grayscale image, written out as a matrix of pixel intensities. Imagine it is darker on the left and brighter on the right, a simple vertical boundary:
image = [
[10, 10, 0, 0, 0],
[10, 10, 0, 0, 0],
[10, 10, 0, 0, 0],
[10, 10, 0, 0, 0],
[10, 10, 0, 0, 0],
]
Now take a 3 by 3 kernel designed to respond to vertical edges, positive on the left column, zero in the middle, negative on the right column:
kernel = [
[1, 0, -1],
[1, 0, -1],
[1, 0, -1],
]
Place the kernel over the top-left 3 by 3 patch of the image, which is rows 0 to 2 and columns 0 to 2, so the patch is all 10s in the first two columns and 0s in the third:
patch = [
[10, 10, 0],
[10, 10, 0],
[10, 10, 0],
]
# element-wise multiply with the kernel, then sum everything
# (10*1 + 10*0 + 0*-1) + (10*1 + 10*0 + 0*-1) + (10*1 + 10*0 + 0*-1)
# = 10 + 10 + 10 = 30
That single number, 30, is the first entry of the output feature map, corresponding to the window's top-left position. Slide the window one column to the right, to columns 1 through 3. That patch is 10, 0, 0 in each row, giving (10*1 + 0*0 + 0*-1) three times, which sums to 10. Slide once more, to columns 2 through 4, where the patch is 0, 0, 0 in each row, giving 0. Since the image is 5 wide and the kernel is 3 wide, there are exactly 3 valid horizontal positions (columns 0-2, 1-3, 2-4), and by symmetry the same 3 positions vertically, so the full output is a 3 by 3 matrix:
output = [
[30, 10, 0],
[30, 10, 0],
[30, 10, 0],
]
Notice what happened: the output is largest in the first column, which is exactly where the kernel's receptive window straddled the boundary between the dark and light halves of the image, and it is zero in the last column, where the window sat entirely in the uniform bright region. The kernel is doing exactly what it was built to do, lighting up where there is a left-to-right intensity change and staying quiet where the image is flat. In a real network you do not hand-design these nine weights; they start random and are learned by gradient descent so that, across many training images, they come to detect whatever patterns reduce the loss. Early layers in trained networks often do end up looking like edge and colour detectors similar to this one, which is a useful fact to keep in your head but not something to rely on in every case.
Here is the same computation done in numpy, written as plain nested loops so you can see every multiplication rather than trusting a single library call:
import numpy as np
image = np.array([
[10, 10, 0, 0, 0],
[10, 10, 0, 0, 0],
[10, 10, 0, 0, 0],
[10, 10, 0, 0, 0],
[10, 10, 0, 0, 0],
], dtype=float)
kernel = np.array([
[1, 0, -1],
[1, 0, -1],
[1, 0, -1],
], dtype=float)
H, W = image.shape
K = kernel.shape[0]
out_h = H - K + 1
out_w = W - K + 1
output = np.zeros((out_h, out_w))
for i in range(out_h):
for j in range(out_w):
patch = image[i:i+K, j:j+K]
output[i, j] = np.sum(patch * kernel)
print(output)
# [[30. 10. 0.]
# [30. 10. 0.]
# [30. 10. 0.]]
This code assumes numpy version 1.x or later, nothing exotic is used. Running it reproduces the 3 by 3 output computed by hand above. Every convolutional layer in every deep learning framework is, at its core, doing this same patch-multiply-sum operation many times over, just with learned kernels, more channels, and heavily optimised code underneath.
A small note on terminology
If you ever read a signal processing textbook, you will see that mathematically, true convolution flips the kernel both horizontally and vertically before sliding it, while the operation described above, sliding without flipping, is technically called cross-correlation. Every deep learning framework performs cross-correlation and calls it convolution anyway. This does not change anything practical: since the kernel's weights are learned rather than fixed in advance, the network is equally happy to learn a flipped or an unflipped version of whatever pattern it needs. You can safely use the word convolution the way the rest of the field does and never think about the flip again, but it is worth knowing in case you come across the distinction in a more mathematical source.
Channels: a filter has depth too
The example above used a single-channel grayscale image. Real images, and real feature maps after the first layer, usually have many channels: 3 for RGB, and often dozens or hundreds inside a network. A kernel for such an input is not just height by width, it is height by width by the number of input channels, and the convolution sums over all of them.
Concretely, if the input is 5 by 5 by 3 (an RGB image, height 5, width 5, 3 channels), a 3 by 3 kernel for it actually has shape 3 by 3 by 3, that is 27 numbers, not 9. At each window position, you multiply all 27 image values by all 27 kernel values and sum everything, red channel contributions, green channel contributions and blue channel contributions all collapsed into one output number. The output of a single filter is therefore still a single 2D feature map, 3 by 3 in this example, even though the input had 3 channels: depth goes in, but a single filter always produces a flat, one-channel map, because it sums across the input depth rather than preserving it.
A convolutional layer almost never uses just one filter. It typically uses many, say 16 or 64 or 512, each with its own independent set of learned weights, each scanning the same input and each producing its own 2D feature map. Stack those maps along a new channel dimension and that is the layer's output: if you apply 16 filters to a 5 by 5 by 3 input, each filter contributing one 3 by 3 map, the layer's output is 3 by 3 by 16. The number of output channels is simply the number of filters you chose to use, it is a design decision you make when building the layer, not something determined by the input.
This also tells you how to count parameters. A single filter over a C-channel input with kernel size K by K has K times K times C weights, plus one bias term per filter. With F filters, the total is F times (K times K times C plus 1). For the example above, K=3, C=3, F=16: each filter has 3*3*3 = 27 weights plus 1 bias, 28 total, times 16 filters gives 448 parameters for the whole layer. Compare that to the fully connected layer from earlier, which needed over 150 million parameters just to look at one modestly sized image once. The difference is weight sharing: those 448 numbers are reused at every spatial position in the image, rather than having a separate set of weights for every pixel and every output unit.
Padding: keeping the edges in play
Look back at the worked example: a 5 by 5 input with a 3 by 3 kernel produced a 3 by 3 output. Every convolution without special handling shrinks the spatial size, because the window cannot be centred on the border pixels without hanging off the edge of the image. There is also a subtler issue hidden in this shrinkage: a pixel in the very corner of the image is only ever included in one window position, while a pixel near the centre is included in up to K times K different window positions (nine, for a 3 by 3 kernel). Corner and edge pixels are therefore underrepresented in the output compared to central pixels, which is rarely what you want, since useful content, an object's edge, a face near the frame's border, can sit anywhere in an image.
Padding solves both problems by adding extra pixels, usually zeros, around the border of the input before sliding the kernel. With a border of zeros, the original corner pixels are no longer at the very edge of the padded image, so the kernel can be centred on them and they contribute to more output positions, and because the output is computed over a larger padded input, the output itself can be made to match the input's original size if you choose the padding amount correctly.
Take the same 5 by 5 image and pad it with one row and one column of zeros on every side, which turns it into a 7 by 7 image:
padded = [
[0, 0, 0, 0, 0, 0, 0],
[0, 10, 10, 0, 0, 0, 0],
[0, 10, 10, 0, 0, 0, 0],
[0, 10, 10, 0, 0, 0, 0],
[0, 10, 10, 0, 0, 0, 0],
[0, 10, 10, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
]
Sliding the same 3 by 3 kernel over this 7 by 7 padded image now gives a 5 by 5 output, matching the original input size. This style, padding just enough so the output size equals the input size (for stride 1), is called same padding. Padding with zero extra pixels, which is what the first example used, is called valid padding, and it is the only option that always shrinks the output.
For an odd kernel size K, with stride 1, the padding amount needed on each side for same padding is (K minus 1) divided by 2. A 3 by 3 kernel needs 1 pixel of padding on each side, a 5 by 5 kernel needs 2, a 7 by 7 kernel needs 3. This is a formula worth memorising, because kernel size 3 with padding 1 is probably the single most common convolution configuration you will see in practice.
Zero padding is the default and most common choice because it is simple and cheap, but it is not the only option. Some frameworks also support reflect padding, where the border is mirrored from the nearby real pixels instead of filled with zeros, and replicate padding, where the edge pixel value is simply repeated outward. These alternatives can reduce artefacts at the image border in some tasks, particularly in generative or restoration models, but zero padding is a perfectly reasonable default and is what you will use in the overwhelming majority of classification and detection networks.
Stride: choosing how far the window jumps
In every example so far, the kernel moved one pixel at a time, left to right and top to bottom. That step size is called the stride, and stride 1 is the default. A stride of 2 means the window jumps two pixels at a time instead of one, visiting only half as many positions along each axis, which roughly halves both the height and width of the output.
Take the padded 7 by 7 image from the previous section and slide the 3 by 3 kernel over it with stride 2 instead of stride 1. The window starts at position (0,0), then jumps to (0,2), then (0,4), rather than visiting (0,1) in between. Horizontally, the valid starting positions in a 7 wide image with a 3 wide kernel are columns 0 through 4 (five positions) if you moved one at a time, but with stride 2 you only land on columns 0, 2 and 4, three positions. The same logic applies vertically, so the output shrinks from 5 by 5 (what same padding with stride 1 gave us) down to 3 by 3.
Stride is a deliberate tool for downsampling, reducing the spatial resolution of the feature maps as information flows deeper into the network, which also reduces the amount of computation every subsequent layer has to do. Some architectures use a strided convolution, stride 2 with a learned kernel, as the main way to shrink feature maps between stages, rather than relying only on the pooling layers described in the next section. Both approaches achieve downsampling; a strided convolution does it with learned weights, pooling does it with a fixed, parameter-free rule.
The output-size formula, and how to use it
All of the examples above are special cases of one general formula. If the input has size W along one axis, the kernel has size K, the padding added on each side is P, and the stride is S, then the output size along that axis is:
output_size = floor((W + 2*P - K) / S) + 1
The floor (round down) matters whenever the numbers do not divide evenly: if the kernel cannot be stepped the full stride and still fit inside the (padded) input, the window simply stops early and the remaining pixels on the far edge are not used in any output position. This is a real, if minor, source of information loss, and it is worth checking your numbers divide cleanly when you are designing a network from scratch, though in practice frameworks compute this for you and will happily run with the dropped-pixel behaviour if the numbers do not divide evenly.
A few worked examples, all using a square input and a square kernel so the same number applies to both height and width:
- W=5, K=3, P=0, S=1: floor((5+0-3)/1)+1 = 3, matching the first hand-computed example (valid padding, no stride)
- W=5, K=3, P=1, S=1: floor((5+2-3)/1)+1 = 5, matching the same-padding example (output size equals input size)
- W=7, K=3, P=0, S=2: floor((7+0-3)/2)+1 = 3, matching the strided example above on the padded 7 by 7 image
- W=32, K=5, P=2, S=1: floor((32+4-5)/1)+1 = 32, a common same-padding setup for a 5 by 5 kernel
- W=224, K=7, P=3, S=2: floor((224+6-7)/2)+1 = 112, roughly the kind of large-kernel, stride-2 first layer used to aggressively shrink a high-resolution input early in a network
Two sanity checks are worth running whenever you set up a layer. First, if P is chosen as (K-1)/2 and S is 1, the output size always equals the input size, regardless of what W actually is, which is a handy way to keep spatial dimensions unchanged while still extracting features. Second, whenever S is greater than 1, the output size is roughly W divided by S, so a stride of 2 roughly halves each spatial dimension, a stride of 4 roughly quarters it, and so on; this is the quick mental estimate to use when sketching an architecture before working out the exact floor-based number.
Pooling: shrinking the map without learning anything
Convolution gives you feature maps, and stride lets those maps come out smaller than the input, but there is a second, older and simpler tool for shrinking feature maps: pooling. A pooling layer slides a window over a feature map just like a convolution does, but instead of a dot product with learned weights, it applies a fixed, parameter-free summary function, most commonly taking the maximum value in the window, or sometimes the average.
Take a small 4 by 4 feature map, perhaps the output of an earlier convolutional layer:
feature_map = [
[1, 3, 2, 0],
[4, 2, 1, 5],
[0, 1, 3, 2],
[2, 4, 0, 1],
]
A 2 by 2 max pooling layer with stride 2, the most common configuration, divides this into four non-overlapping 2 by 2 blocks and takes the maximum of each. The top-left block is [[1,3],[4,2]], whose maximum is 4. The top-right block is [[2,0],[1,5]], maximum 5. The bottom-left block is [[0,1],[2,4]], maximum 4. The bottom-right block is [[3,2],[0,1]], maximum 3. The pooled output is:
pooled = [
[4, 5],
[4, 3],
]
A 4 by 4 map has become a 2 by 2 map, a quarter of the original number of values, with zero learned parameters involved, just a maximum operation applied four times. Average pooling works identically except it computes the mean of each block instead of the max: the same four blocks would give (1+3+4+2)/4 = 2.5, (2+0+1+5)/4 = 2, (0+1+2+4)/4 = 1.75, and (3+2+0+1)/4 = 1.5.
Pooling output size follows the same formula as convolution, just with no learned kernel and, by convention, usually no padding: output_size = floor((W - K)/S) + 1. With K=2 and S=2, that simplifies to roughly half the input size along each axis, which is why 2 by 2 pooling with stride 2 is such a common default, it cleanly halves both dimensions.
There are three reasons pooling is useful. First, it reduces the amount of computation every later layer has to perform, since those layers now operate on smaller feature maps. Second, max pooling in particular gives a small amount of translation invariance: if the strongest activation in a 2 by 2 block shifts by one pixel due to the object in the image moving slightly, the maximum value of that block often stays the same or very similar, so the pooled output is less sensitive to small shifts than the raw feature map was. Third, because pooling keeps only a summary statistic and discards the exact position of that value within its window, it forces the network to rely on the presence of a feature rather than its precise pixel location, which tends to help with generalisation.
Max pooling and average pooling behave differently in practice. Max pooling tends to preserve sharp, strong activations, which is useful for picking out whether a feature, an edge, a texture, a learned pattern, fired strongly anywhere in a region. Average pooling smooths activations out, which can blur sharp signals but is sometimes preferred at the very end of a network, where a technique called global average pooling takes the average of an entire feature map down to a single number per channel, as a way of summarising a whole feature map before a final classification layer. You will meet that specific use when the path covers classic architectures in a later article; for now the important thing is to recognise average and max pooling as two different, equally valid ways of summarising a window, each with a different bias in what information it keeps.
Receptive field: how far back a single number can see
A single value in a feature map is the result of a small window of the input (or of the previous layer's feature map). But once you stack several convolutional layers, a value in a deep feature map is influenced by a much larger region of the original image than any single kernel's size would suggest, because each layer's window is itself built from windows of the layer before it. The total region of the original input that affects a given output value is called its receptive field.
Here is the simplest way to see it grow. Suppose you stack two convolutional layers, each with a 3 by 3 kernel, stride 1, and same padding so spatial sizes do not shrink. A single value in the output of the first layer depends on a 3 by 3 patch of the original image, so its receptive field is 3 by 3. Now look at a single value in the output of the second layer. It depends on a 3 by 3 patch of the first layer's output, but each of those nine values itself depends on its own 3 by 3 patch of the original image, and those nine patches overlap and together span a 5 by 5 region of the original image, not 3 by 3 and not 6 by 6. The general rule, for stride-1 layers, is that receptive field grows by (kernel size minus 1) with each additional layer: starting at 3 for the first layer, adding (3-1)=2 for the second layer gives 5, adding another 2 for a third 3 by 3 layer would give 7, and so on.
This is the reasoning behind a design idea you will see again when this path covers classic architectures: stacking several small kernels, say two 3 by 3 convolutions in a row, can cover the same receptive field as one larger kernel, in this case one 5 by 5 convolution, while using fewer parameters and adding an extra non-linearity in between. With C input and output channels throughout, a single 5 by 5 convolution uses 5 times 5 times C times C weights. Two stacked 3 by 3 convolutions use 2 times (3 times 3 times C times C) weights. For C=64, that is 102,400 weights for the single 5 by 5 layer against 73,728 weights for the two stacked 3 by 3 layers, fewer parameters for the same receptive field, plus an additional activation function sitting between the two smaller layers, which gives the network one more opportunity to introduce non-linearity. This is exactly the kind of trade-off you will see architects of real networks lean on, and it is covered in more depth, with the historical architectures that popularised it, in part 4 of this path.
Pooling and stride also affect the receptive field, and they affect it faster than stacking stride-1 convolutions does, because they compress spatial information at each step rather than just widening the window gradually. A pooling or stride-2 layer effectively doubles the ground covered by the receptive field of everything that comes after it, which is one reason deep networks interleave convolutions with occasional pooling or strided layers rather than using stride 1 everywhere: it lets receptive fields grow fast enough to eventually cover the whole image after a manageable number of layers.
Seeing the shapes in code
It helps to confirm all of this against a real framework rather than only hand calculation. The snippet below assumes PyTorch version 2.x; the specific version matters less here than the general behaviour, which has been stable across PyTorch 1.x and 2.x releases.
import torch
import torch.nn as nn
torch.manual_seed(0)
# a batch of 1 RGB image, 32 by 32 pixels
x = torch.randn(1, 3, 32, 32)
# same padding: kernel 3, padding 1, stride 1 keeps spatial size unchanged
conv = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, stride=1, padding=1)
y = conv(x)
print("after conv:", y.shape) # torch.Size([1, 16, 32, 32])
# a strided convolution that halves spatial size and changes channels
conv_stride = nn.Conv2d(in_channels=16, out_channels=32, kernel_size=3, stride=2, padding=1)
z = conv_stride(y)
print("after strided conv:", z.shape) # torch.Size([1, 32, 16, 16])
# a pooling layer that also halves spatial size, with no learned parameters
pool = nn.MaxPool2d(kernel_size=2, stride=2)
p = pool(z)
print("after max pool:", p.shape) # torch.Size([1, 32, 8, 8])
# parameter counts, confirming the formula from earlier
print("conv params:", sum(t.numel() for t in conv.parameters()))
# 16 * (3*3*3 + 1) = 16 * 28 = 448
print("pool params:", sum(t.numel() for t in pool.parameters()))
# 0, pooling has no learnable weights
Running this prints the shapes noted in the comments. The first convolution keeps the spatial size at 32 by 32 because padding 1 with kernel 3 and stride 1 is exactly the same-padding configuration from earlier, while changing the channel count from 3 to 16, since that is how many filters were requested. The second convolution, with stride 2, roughly halves the spatial size, from 32 to 16, confirming the output-size formula, while also changing the channel count again. The pooling layer then halves the spatial size a second time, from 16 to 8, using no learned weights at all, which the final print statement confirms directly by showing a parameter count of zero for the pooling layer against 448 for the first convolution, matching the hand calculation from the channels section above.
Common mistakes
- Forgetting that valid padding shrinks the output every single layer: stacking several convolutions with no padding on a modest input can shrink the feature map down to nothing after only a handful of layers, which shows up as a runtime error about a negative or zero dimension rather than a gentle warning.
- Mismatching kernel depth and input channels: a kernel's depth must equal the number of channels in whatever it is being applied to; this is handled automatically by frameworks when you declare in_channels correctly, but it is a frequent source of shape errors when channel counts change between layers and the declared in_channels does not match the actual previous layer's out_channels.
- Treating stride and pooling as interchangeable without checking: both shrink spatial size, but a strided convolution learns how to summarise its window, while pooling applies a fixed rule; swapping one for the other changes both the parameter count and, often, the quality of what the network learns, so the choice should be deliberate rather than automatic.
- Assuming kernel size alone tells you how much context a neuron has: a single 3 by 3 kernel only looks at a 3 by 3 patch, but a neuron several layers deep in the network can have a receptive field covering a large fraction of the image, purely from stacking; judge a network's field of view from the whole stack, not from one layer's kernel size.
- Reaching for a large kernel to capture more context when stacking smaller kernels with padding to preserve size would reach the same receptive field with fewer parameters and an extra non-linearity in between, as shown in the receptive field section.
- Confusing the mathematical convolution (which flips the kernel) with the cross-correlation that every deep learning framework actually implements; this almost never matters in practice since the weights are learned, but it can cause confusion when comparing a framework's behaviour against a textbook definition.
- Applying average pooling where the goal is to detect whether a sharp feature fired anywhere in a region, or applying max pooling where a smooth summary is wanted; the two are not drop-in replacements for each other, they encode different assumptions about what matters in the window.
Summary and what comes next
Convolution replaces a fully connected layer's one-weight-per-pixel-per-unit approach with a small, shared kernel that slides across the image, multiplying and summing a local patch at every position, which gives both far fewer parameters and a built-in sensitivity to local, repeatable patterns wherever they occur. Padding decides whether the output shrinks (valid padding) or keeps the input's spatial size (same padding), and it also fixes the problem of border pixels being used in fewer window positions than central ones. Stride decides how large a step the window takes between positions, letting a convolution downsample deliberately rather than only through separate pooling layers. Pooling, most often max pooling, shrinks feature maps using a fixed, parameter-free rule rather than learned weights, trading a small amount of positional precision for reduced computation and a touch of translation invariance. All four ideas combine through one formula, output_size = floor((W + 2P - K)/S) + 1, which tells you exactly what shape every layer in a network will produce. Stacking these layers also grows the receptive field, the region of the original image that a single deep feature actually depends on, faster than any single kernel size would suggest.
With these mechanics in hand, you are ready for part 3, Building a CNN Layer by Layer, which takes convolution, padding, stride and pooling, the pieces covered here, and assembles them alongside activation functions and a classification head into a complete, working convolutional network, the same kind of architecture you will train, inspect and fine-tune throughout the rest of this path.
Comments (0)
No comments yet. Be the first to share your thoughts.