← All tutorials

On this page

1 Convolution 2 Size, stride and padding 3 Pooling 4 A small CNN, layer by layer 5 Convolution problems and fixes 6 References

CNN Basics: Convolution, Stride and Pooling

A convolutional network (CNN) reads an image and outputs a few numbers, such as one score per digit. Each layer slides small grids of weights, its kernels, over the input and outputs feature maps of where their patterns appear; pooling and stride shrink those maps, and a final layer turns them into scores.

1 a kernelslides2 size andstride3 poolinglayers4 small CNNon a digitthe rest of the CNN seriesReceptive Fields, Dilation and Causal ConvolutionsClassic CNN Architectures: AlexNet to ResNetEfficient CNNs: MobileNet and EfficientNet
Figure 1. Sections 1 to 4 build one layer and a small network.

1 Convolution

A convolution layer takes an image and outputs one map per kernel, showing where that kernel's pattern appears. It slides one small grid of weights, the kernel (or filter), across the input and reuses it at every position. At each position the kernel multiplies each weight by the input value under it, adds the products and adds a bias:

One output value is the kernel's match with the patch under it.
patch = x[i : i+k, j : j+k]
The patch is the k by k square of the input x whose top-left corner sits at row i, column j; the colon means rows i up to, but not including, i + k. Moving i or j by one slides the kernel one pixel. With several channels the patch covers all of them.
z[i,j] = Σ (patch ⊙ K) + b
The match score z[i,j] is a weighted sum: ⊙ multiplies each cell of the patch by the matching cell of the kernel K, and Σ adds the products. A patch with the kernel's pattern of bright and dark gives mostly positive products and a high score; the opposite pattern gives a strong negative one. The bias b is one learned number added to every score of this kernel; it shifts all the scores up or down before the ReLU that follows, so it sets how strong a match must be to pass.
kernelmultiply and add+ biasinputoutput
Figure 2. One kernel position gives one output cell; sliding fills the rest.

A fully connected layer (see the MLP tutorial) instead gives every input value its own weight. For one output on a 224×224 color image with three channels (red, green, blue):

Weights for one output in a fully connected layer and in one 3×3 kernel.
FC = 224 · 224 · 3 = 150,528
A fully connected layer, FC, gives every input value its own weight for this output, so its count is the whole image: height times width times channels. Double the image side and the count grows four times.
3×3 kernel = 3 · 3 · 3 = 27
A kernel sees only a 3 by 3 patch through all 3 channels, so its count depends on the patch, not the image. The same 27 weights slide to every position, so a larger image needs no new weights.

That is about 5,600 times fewer weights, and the reuse is called shared weights.

Reusing one kernel everywhere gives three properties. Shift the input and the output map shifts by the same amount, which is translation equivariance (invariance would mean the output does not change at all). The same 27 weights replace 150,528, which is parameter efficiency. Each output depends on a small patch, because nearby pixels usually belong together, which is locality.

A layer runs many kernels, and each one makes one output channel, also called a feature map. A nonlinearity follows, usually ReLU, which keeps positive values and sets negative ones to zero, so a stack of layers can fit patterns that one weighted sum cannot. A one-dimensional kernel is the same operation as an FIR (finite impulse response) filter in signal processing, a fixed weighted sum of the last few samples.

In PyTorch, nn.Conv2d(3, 16, 3) is one layer of 16 kernels, each 3×3 over the 3 color channels. Each output map is 222 wide: the kernel must fit inside the image, so 1 pixel is lost from each edge.

import torch
import torch.nn as nn

conv = nn.Conv2d(3, 16, 3)
x = torch.randn(1, 3, 224, 224)
print(conv.weight.shape)
# [16, 3, 3, 3]
a = torch.relu(conv(x))
print(a.shape)
# [1, 16, 222, 222]
  1. 1Builds the layer and prints its weight: 16 kernels K, each 3×3 over 3 channels.
  2. 2Slides every kernel over the image as in Figure 2 and applies ReLU: 16 maps, each 222 wide.

1.1 Kernels as pattern detectors

A kernel scores high where the patch looks like the kernel and low elsewhere. The NumPy slide below, for one channel and a square input, stores the match score z[i, j] of Section 1, without the bias, at every output cell. A k-wide kernel fits n − k + 1 times across an n-wide input.

import numpy as np

def conv2d(x, K):
    # x: [n, n], K: [k, k]
    n, k = len(x), len(K)
    out = n - k + 1
    z = np.zeros((out, out))
    cells = np.ndindex(out, out)
    for i, j in cells:
        patch = x[i:i+k, j:j+k]
        z[i, j] = np.sum(patch*K)
    return z  # [out, out]
  1. 1Sets the output size n − k + 1 and an empty map z.
  2. 2Visits every cell, cuts the patch under the kernel and stores its match score, one kernel position of Figure 2.
Input: a bright vertical bar and a short diagonal
Output: 10×10 activation map
strong positivestrong negative

Figure 3. The vertical edge kernel scores the bar’s two sides with opposite signs.


2 Size, stride and padding

Three hyperparameters set how large the output map is, by deciding how many positions the kernel visits. The kernel size k is the patch it sees. The stride s is how far it moves each step. The padding p is how many zeros go around the input. Valid padding adds none, and same padding adds enough to keep the output as large as the input.

2.1 Output size

The output size counts the kernel placements that fit along the padded input.
out = ⌊n − k + 2ps⌋ + 1
The output size counts the places the kernel can sit. The padded input is n + 2p wide, a kernel of size k leaves n − k + 2p pixels of room to slide, and dividing that room by the stride s counts the steps. ⌊ ⌋ rounds down because a step that would hang off the edge is dropped, and the + 1 counts the first placement. A larger kernel or stride shrinks the output; padding gives size back. The pattern detector tool, Figure 3, has n = 12, k = 3, p = 0 and s = 1, so out = 10.

The NumPy function of Section 1.1 with stride and padding added runs the output size card exactly.

import numpy as np

def conv2d(x, K, s=1, p=0):
    x = np.pad(x, p)
    # x: [n, n] with its border
    n, k = len(x), len(K)
    out = (n - k) // s + 1
    z = np.zeros((out, out))
    cells = np.ndindex(out, out)
    for i, j in cells:
        r, c = i * s, j * s
        patch = x[r:r+k, c:c+k]
        z[i, j] = np.sum(patch*K)
    return z
  1. 1Adds p zeros around every edge.
  2. 2Sets the output size from the output size card.
  3. 3Starts each patch s pixels after the last one, then scores it as before.

2.2 Compute and memory cost

Multiply-adds for one convolution layer on one image.
MACs = out² · Cin · Cout · k²
Every one of the out² positions of every one of the Cout kernels multiplies a k by k patch through all Cin input channels and adds the products. The weights depend only on Cin · Cout · k²; the out² factor is why early, large maps dominate the run time. The first PyTorch layer of Section 1 costs 222² · 3 · 16 · 9, about 21.3 million multiply-adds, and a stride of 2 would cut that about 4 times.

Memory follows the maps, not the weights, because training keeps every layer’s output for the backward pass (Figure 4). The full argument list and defaults are in the PyTorch Conv2d docs.

weights432 = 3 · 16 · 9output maps788,544 = 16 · 222 · 222the maps take 3.2 MB as 32-bit floats
Figure 4. For the first layer on one image, the maps hold 1,800 times more numbers than the weights.

3 Pooling

Pooling keeps one number per window, so the map gets smaller and each later output covers more of the image. It has no weights. Max pooling keeps only the largest value, so the other values and that value's exact place in the window are lost.

input 4×4max poolaverage pool132049110256117394.25212175.25
Figure 5. Each 2×2 window keeps its largest value or its mean.

In PyTorch, each pooling layer below takes 16 maps of 32×32. The 2 is the window, and the stride defaults to the window. A larger window shrinks the map faster and throws away more position; a window of 1 does nothing.

import torch
import torch.nn as nn

x = torch.randn(1, 16, 32, 32)
max_pool = nn.MaxPool2d(2)
avg_pool = nn.AvgPool2d(2)
gpool = nn.AdaptiveAvgPool2d(1)
print(max_pool(x).shape)
# [1, 16, 16, 16]
print(avg_pool(x).shape)
# [1, 16, 16, 16]
print(gpool(x).shape)
# [1, 16, 1, 1]
  1. 1Builds the max and average pools of Figure 5 and a global average pool.
  2. 2Prints the shapes: the 2×2 pools halve each side, the global pool keeps one number per map.

Average pooling suits audio and time series, where the energy in a window matters more than one peak. Global average pooling, the last line, leaves one number per channel and can replace a large fully connected head, which removes that head's many weights, a common source of overfitting (memorizing the training images); adaptive pooling picks the window itself so any input size gives the output size you ask for.

A convolution with a stride above 1 also downsamples, with learned weights. Both can cause aliasing: detail finer than the new sampling grid folds back as a false coarse pattern (Aliasing and Anti-Aliasing Filters).

Striding lowers the sampling rate, and with it the Nyquist frequency.
fNyquist = fs2s
A signal sampled at rate fs can only hold frequencies up to half of it, the Nyquist frequency. A stride s keeps one sample in s, so the rate falls to fs divided by s, and the limit falls with it: stride 2 halves it. Content above the new limit does not vanish; it folds back as a false low frequency unless a low-pass filter removes it first, and a learned filter is not guaranteed to.

A blur before the stride removes that fine detail first. BlurPool (Zhang 2019) does this, and its outputs change less when the input shifts by one pixel; it costs one fixed blur per downsampling layer and no weights. Its official code uses a 4-tap blur by default; a wider blur removes more aliasing but also more real detail. Zero padding adds an artificial dark border; reflect padding mirrors the edge outward instead.


4 A small CNN, layer by layer

A tiny CNN takes a 28×28 image of a handwritten digit and outputs ten scores, one per digit. Each stage shrinks the maps and adds more of them, so the last stage holds a few numbers that each summarize a large part of the digit. In PyTorch the whole net is one nn.Sequential; each 5×5 convolution trims 4 pixels from each side length and each pool halves it:

import torch.nn as nn

net = nn.Sequential(
    nn.Conv2d(1, 6, 5),
    nn.ReLU(),
    nn.MaxPool2d(2),     # 6×12×12
    nn.Conv2d(6, 16, 5),
    nn.ReLU(),
    nn.MaxPool2d(2),     # 16×4×4
    nn.Flatten(),        # 256
    nn.Linear(256, 10),
)
  1. 1Stages 2 and 3 of Figure 6: six 5×5 kernels, ReLU, pool.
  2. 2Stages 4 and 5: sixteen 5×5 kernels over all six maps, ReLU, pool.
  3. 3Stage 6: flatten the 16 maps of 4×4 and score the ten digits.
Stage 1 of 6: Input

Figure 6. Real shapes; the kernels and weights are fixed, not learned.

28×28, 1 map

A 28×28 handwritten 7. Everything the network knows starts here.

Each stage's size and the operation that changes it.
28×28, 1 map
The input is one grayscale map of 28 by 28 pixels, the only thing the network sees.
z = Σ (patch ⊙ K) + b
Each cell of a feature map is the kernel's match score z with the patch under it, plus the bias, the same weighted sum as in Section 1.
a = max(0, z)
The output cell a keeps a positive score and turns a negative one into zero, so a map is positive only where its pattern is present.
28 → 24, 6 maps
Six 5×5 kernels make six maps. A 5 wide kernel fits 24 times across 28 pixels with no border added, so each map loses 2 pixels from each edge.
24 → 12
A 2×2 max pool keeps the largest value in each 2 by 2 square. Each side halves, and a stroke that moves by one pixel still lands in the same square.
12 → 8, 16 maps
Each of the sixteen 5×5 kernels reads all six maps at once, so it can combine edges found in different maps into corners and strokes.
8 → 4
A second pool halves each side again, so each remaining cell summarizes a large part of the digit.
256 → 10 scores
The 16 maps of 4 by 4 are flattened into one list of 256 numbers, and a fully connected layer weighs all of them into one score per digit; the highest score is the answer.

4.1 LeNet-5 accuracy

The walkthrough net is a cut-down sketch with 5,142 weights. Its full-size ancestor, LeNet-5 (LeCun et al. 1998), takes 32×32 inputs, adds a third convolution layer and an 84-unit layer, and has about 60,000 trainable weights. Trained on MNIST's 60,000 handwritten digits and tested on 10,000 others, it misclassifies under 1% of the test digits (Figure 7). Every digit at every position trains the same kernels, which is why so few weights suffice.

LeNet-5 0.95% + 540,000 distorted 0.8%
Figure 7. MNIST test error of LeNet-5 (the LeNet paper).

5 Convolution problems and fixes

Both problems come from how a layer samples and stores its maps.

SymptomLikely causeFix
A one-pixel shift changes the outputStride without a blur aliases fine detailBlur before downsampling (BlurPool)
Out of memory on large inputsActivation maps, not weights, fill memory in trainingDownsample earlier or use a smaller batch

How much of the image does one output see, and how do you grow that view without more weights? Receptive Fields, Dilation and Causal Convolutions answers that.


6 References

  1. LeCun, Y., et al. (1998). Gradient-Based Learning Applied to Document Recognition. Proceedings of the IEEE.
  2. Zhang, R. (2019). Making Convolutional Networks Shift-Invariant Again. ICML. arXiv:1904.11486. Code: antialiased-cnns.
  3. PyTorch. torch.nn.Conv2d documentation. docs.pytorch.org.