ml-models / Model architectures / Convolutional networks core

Convolutional networks

A convolution slides one small set of weights across an image and takes a dot product at every position. That single idea gives you parameter sharing, translation equivariance and a receptive field that grows with depth. This page works through the arithmetic (kernels, stride, padding, dilation, channels, pooling and the output-size formula) and then builds a LeNet-style and a ResNet-style network shape by shape.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Convolution as a sliding dot product
  5. Stride, padding, dilation and the output size
  6. Channels, 1 × 1 convolutions and groups
  7. Pooling
  8. Receptive fields
  9. Parameter sharing and translation equivariance
  10. Residual blocks
  11. Worked example
  12. One kernel, four settings
  13. Pooling a 4 × 4 map
  14. Output sizes
  15. A LeNet-style stack
  16. ResNet-18, shape by shape
  17. Implementation
  18. Tradeoffs
  19. Check yourself
  20. Connections
  21. Read first
  22. Read next
  23. References

Problem

A 224 × 224 colour image has 150,528 numbers. Feed it to a fully connected layer with even 200,704 outputs and you need about 30.2 billion weights in the first layer alone. Worse, those weights learn nothing that transfers across the image: a cat-ear detector in the top-left corner is a separate set of weights from the same detector one pixel to the right, so the network needs to see ears at every position to learn them everywhere.

Images have structure a dense layer ignores. Nearby pixels belong together, and the same pattern means the same thing wherever it appears. A good layer for images should look at small neighbourhoods and reuse its weights at every position.

Intuition

Take a 3 × 3 stencil of weights, a kernel. Lay it over the top-left corner of the image, multiply each weight by the pixel under it and add the nine products: that is one output number. Slide the stencil one pixel right and repeat. Sweep the whole image and you get a new grid of numbers, a feature map, that is large wherever the image locally looks like the kernel.

A layer has many kernels, each producing its own map, so the output is a stack of maps: channels. The next layer's kernels look at small neighbourhoods across all those channels, so they detect combinations of the first layer's patterns: edges become corners, corners become textures, textures become parts. Every layer sees a slightly larger region of the original image than the last, and downsampling (stride or pooling) makes that region grow quickly. By the end, each number summarises a large part of the image, and a small classifier on top reads off the answer.

Mechanics

Convolution as a sliding dot product

For one input channel and one kernel K of size k × k, output position (i, j) is

out[i, j] = bias + Σa Σb K[a, b] · x[i·s + a·d, j·s + b·d]

where s is the stride and d the dilation (both 1 in the plain case). With several input channels the sum also runs over every channel, so each kernel is a Cin × k × k block and each output channel has its own kernel and bias. Deep learning libraries compute this cross-correlation; the textbook convolution flips the kernel first, which makes no difference when the kernel is learned.

A Conv2d(Cin, Cout, k) therefore has a weight of shape [Cout, Cin/groups, k, k] and a bias of shape [Cout], and maps [N, Cin, H, W] to [N, Cout, Hout, Wout].

A 3×3 kernel sliding over a 6×6 image Left, a 6 by 6 image whose left three columns are 3 and right three columns are 0. A 3 by 3 window starting at column 1 is highlighted. Middle, the kernel with columns 1, 0, −1. Right, the 4 by 4 output: every row is 0, 9, 9, 0. The highlighted window produces the output at row 0, column 1, which is 9. input 6×6 3 3 3 0 0 0 3 3 3 0 0 0 3 3 3 0 0 0 3 3 3 0 0 0 3 3 3 0 0 0 3 3 3 0 0 0 ⋆ kernel 3×3 1 0 -1 1 0 -1 1 0 -1 = output 4×4 0 9 9 0 0 9 9 0 0 9 9 0 0 9 9 0 out = ⌊(6 + 0 − 3)/1⌋ + 1 = 4 per side 9 weights, reused at all 16 positions highlighted: 1·3 + 0·3 + -1·0 + 1·3 + 0·3 + -1·0 + 1·3 + 0·3 + -1·0 = 9

The kernel subtracts the right column of its window from the left one, so it responds only where bright meets dark: a vertical-edge detector. The flat regions give 0.

Stride, padding, dilation and the output size

  • Stride s moves the window s pixels at a time, so the output shrinks by about a factor s.
  • Padding p adds p rows and columns of zeros (or reflected or replicated values, padding_mode) around the input, so border pixels are covered as often as central ones and the size can be preserved. padding="same" computes it for you when the stride is 1.
  • Dilation d spaces the kernel's taps d pixels apart: a 3 × 3 kernel with d = 2 covers a 5 × 5 area with nine weights.

Along each spatial dimension, PyTorch's Conv2d and MaxPool2d both give

Hout = ⌊(Hin + 2p − d·(k − 1) − 1) / s⌋ + 1

The floor means extra pixels that do not fill a final window are dropped. Pooling's ceil_mode=True rounds up instead, keeping a partial last window as long as it starts inside the input.

Channels, 1 × 1 convolutions and groups

Each output channel looks at every input channel, so a 3 × 3 convolution from 64 to 128 channels has 128 × 64 × 3 × 3 = 73,728 weights. A 1 × 1 convolution looks at one pixel but all channels: it is a linear layer applied at every position, used to change the channel count cheaply. groups=g splits the channels into g independent groups; with groups=Cin each channel gets its own kernel (a depthwise convolution), which with a following 1 × 1 convolution is the depthwise-separable block in MobileNet-style networks.

Pooling

Pooling replaces each window by a summary with no weights: MaxPool2d(2) keeps the largest value in each 2 × 2 block, AvgPool2d(2) the mean. The stride defaults to the kernel size, so windows do not overlap and each side halves. Max pooling makes the output insensitive to small shifts of a feature inside its window. AdaptiveAvgPool2d(1) averages each whole channel to one number whatever the input size, which is how modern CNNs go from feature maps to a vector for the classifier ("global average pooling").

Receptive fields

The receptive field of an output unit is the region of the input that can affect it. Track two numbers through the layers: the receptive field r and the jump j (how many input pixels one step in this layer's output corresponds to). Starting from r = 1, j = 1, each layer with kernel k, stride s and dilation d does

r ← r + (k − 1)·d·j    j ← j·s

So a 3 × 3 layer adds 2j pixels: depth grows the field linearly, and each stride multiplies how fast later layers grow it. Three stacked 3 × 3 convolutions see 7 × 7, the same as one 7 × 7, with 110,592 instead of 200,704 weights at 64 channels and two extra nonlinearities in between. That observation from VGG is why nearly every modern CNN uses 3 × 3 kernels. The theoretical field is an upper bound; the effective field, where the gradient is large, is smaller and roughly Gaussian.

Parameter sharing and translation equivariance

The same kernel is used at every position, so a convolution has Cout·Cin·k² weights no matter how big the image is. Sharing also makes the layer translation equivariant: shift the input and the feature map shifts by the same amount (exactly, for stride 1 away from the borders). Pooling and the final global average then make the prediction roughly invariant to where the object is. These are built-in assumptions, an inductive bias, which is why CNNs learn from far less data than a model that has to discover locality on its own.

Residual blocks

Stacking more plain convolution layers eventually makes training error go up, not down: the deeper network is harder to optimise even though it could copy the shallower one and add identity layers. ResNet fixes this by having each block learn a correction to its input:

y = ReLU(F(x) + shortcut(x))    F = conv3×3 → BN → ReLU → conv3×3 → BN

If F learns nothing, the block passes x straight through, so extra depth cannot hurt, and the gradient has a direct path back through every addition (see normalization and residuals, where the transformer uses the same trick). When a block halves the resolution and doubles the channels, x and F(x) no longer have the same shape, so the shortcut becomes a strided 1 × 1 convolution with batch norm (the "downsample" path). The convolutions before a batch norm have bias=False, since the norm's own shift makes a bias redundant.

A ResNet basic block with a downsampling shortcut The input x of shape [64, 56, 56] goes along two paths. The main path is a 3 by 3 convolution with stride 2 to 128 channels, batch norm, ReLU, a second 3 by 3 convolution and batch norm, giving [128, 28, 28]. The shortcut is a 1 by 1 convolution with stride 2 and batch norm, also giving [128, 28, 28]. The two are added and passed through a ReLU. When the shapes already match, the shortcut is the identity. x [64, 56, 56] conv 3×3/2 64→128 BN ReLU conv 3×3 128→128 BN shortcut: conv 1×1/2 + BN identity when shapes match + ReLU → y [128, 28, 28] F(x)

The first block of ResNet-18's layer2. Its second block, and every block of layer1, keeps the shape and uses the identity shortcut.

ComponentShape/ParamsMeaningSource
nn.Conv2d [N, Cin, H, W] → [N, Cout, Hout, Wout] 2-D cross-correlation; the docstring gives the output-size formula. conv.py
_ConvNd.__init__ checks groups, padding="same" only with stride 1 Shared constructor for every convolution. conv.py
weight, bias [Cout, Cin/groups, k, k], [Cout] One kernel per output channel, shared over positions. conv.py
padding="same" total d·(k − 1), left gets the smaller half Padding that keeps the size at stride 1. conv.py
reset_parameters Kaiming uniform, a = √5; bias U(±1/√fan_in) Default initialisation. conv.py
Conv2d._conv_forward non-zero padding_mode pads first, then convolves with padding 0 Calls F.conv2d. conv.py
_MaxPoolNd stride defaults to kernel_size Shared pooling constructor. pooling.py
nn.MaxPool2d [N, C, H, W] → [N, C, Hout, Wout]; ceil_mode Maximum over each window, no weights. pooling.py
nn.AvgPool2d count_include_pad, divisor_override Mean over each window. pooling.py
nn.AdaptiveAvgPool2d output_size, any input size Global average pooling with output_size=1. pooling.py

Worked example

One kernel, four settings

The 6 × 6 image and edge kernel from the diagram. The highlighted output is the dot product of the kernel with the window starting at row 0, column 1: 1·3 + 0·3 + -1·0 + 1·3 + 0·3 + -1·0 + 1·3 + 0·3 + -1·0 = 9. Changing the settings changes the output size exactly as the formula says:

SettingSizeFirst row
stride 1, no padding4 × 40, 9, 9, 0
stride 1, padding 16 × 6-6, 0, 6, 6, 0, 0
stride 2, padding 13 × 3-6, 6, 0
dilation 2, no padding2 × 29, 9

With padding, the kernel also sees the zero border: at the left edge the window covers zeros on its left and 3s on its right, which reads as a dark-to-bright edge, −9. The edge you get depends on what you pad with, which is why padding_mode="reflect" exists.

Pooling a 4 × 4 map

Map rows: [1, 3, 2, 0], [4, 6, 1, 1], [0, 2, 5, 7], [1, 1, 3, 2]. With 2 × 2 windows and stride 2: max pooling gives [6, 2], [2, 7]; average pooling gives [3.5, 1.0], [1.0, 4.25]. The 6 in the top-left window could move anywhere in that window and max pooling would still output 6.

Output sizes

LayerHin⌊(H + 2p − d(k − 1) − 1)/s⌋ + 1Hout
3×3, stride 1, no padding32⌊(32 + 0 − 2 − 1)/1⌋ + 130
3×3, stride 1, padding 132⌊(32 + 2 − 2 − 1)/1⌋ + 132
3×3, stride 2, padding 132⌊(32 + 2 − 2 − 1)/2⌋ + 116
5×5, stride 1, no padding32⌊(32 + 0 − 4 − 1)/1⌋ + 128
3×3, dilation 2, padding 232⌊(32 + 4 − 4 − 1)/1⌋ + 132
7×7, stride 2, padding 3224⌊(224 + 6 − 6 − 1)/2⌋ + 1112
1×1, stride 256⌊(56 + 0 − 0 − 1)/2⌋ + 128

3 × 3 with padding 1 keeps the size; adding stride 2 halves it. Those two settings make up most of a modern CNN.

A LeNet-style stack

A small classifier for 32 × 32 grayscale digits, in the shape of LeNet-5 (with ReLU and max pooling instead of the original's tanh and subsampling):

LayerOutput shapeParameters
input[1, 32, 32]0
Conv2d(1, 6, 5) + ReLU[6, 28, 28]156
MaxPool2d(2)[6, 14, 14]0
Conv2d(6, 16, 5) + ReLU[16, 10, 10]2,416
MaxPool2d(2)[16, 5, 5]0
Flatten[400]0
Linear(400, 120)[120]48,120
Linear(120, 84)[84]10,164
Linear(84, 10)[10]850
Total61,706

The two convolutions hold 2,572 of the 61,706 parameters; the first linear layer alone holds 48,120. Convolutions are cheap in parameters and expensive in compute: the first one reuses its 156 weights at 784 positions.

ResNet-18, shape by shape

StageOutput shapeParameters
input[3, 224, 224]0
conv1 7×7/2 + BN + ReLU[64, 112, 112]9,536
maxpool 3×3/2[64, 56, 56]0
layer1: 2 blocks[64, 56, 56]147,968
layer2: 2 blocks (first block downsamples)[128, 28, 28]525,568
layer3: 2 blocks (first block downsamples)[256, 14, 14]2,099,712
layer4: 2 blocks (first block downsamples)[512, 7, 7]8,393,728
adaptive avgpool → flatten[512]0
fc 512→1000[1000]513,000
Total11,689,512

Each stage halves the resolution and doubles the channels, so the work per layer stays roughly constant. The total, 11,689,512, is the published ResNet-18 size (batch norm's γ and β included, its running statistics excluded). 72% of the parameters sit in layer4, where 512-channel 3 × 3 kernels cost 2,359,296 weights each, while the spatial size there is only 7 × 7.

Receptive field: conv1 gives 7, the max pool 11, and after all sixteen 3 × 3 convolutions each output unit of layer4 sees 435 × 435 input pixels with a jump of 32. That is wider than the 224-pixel image, so every final unit can see the whole image, and the global average pool combines 7 × 7 such views.

Implementation

A convolution in plain Python, the way the formula reads (the tested helper behind this page's numbers):

def conv2d(x, weight, bias=None, stride=1, padding=0, dilation=1):
    # x: [C_in][H][W], weight: [C_out][C_in][k][k]
    planes = [pad2d(plane, padding) for plane in x]
    k = len(weight[0][0])
    out_h = (len(x[0]) + 2 * padding - dilation * (k - 1) - 1) // stride + 1
    out_w = (len(x[0][0]) + 2 * padding - dilation * (k - 1) - 1) // stride + 1
    return [
        [
            [
                (bias[o] if bias else 0) + sum(
                    kernels[c][a][b] * planes[c][i * stride + a * dilation][j * stride + b * dilation]
                    for c in range(len(x)) for a in range(k) for b in range(k)
                )
                for j in range(out_w)
            ]
            for i in range(out_h)
        ]
        for o, kernels in enumerate(weight)
    ]

The same networks in PyTorch:

import torch
from torch import nn
import torch.nn.functional as F

lenet = nn.Sequential(
    nn.Conv2d(1, 6, 5), nn.ReLU(), nn.MaxPool2d(2),     # [N, 6, 14, 14]
    nn.Conv2d(6, 16, 5), nn.ReLU(), nn.MaxPool2d(2),    # [N, 16, 5, 5]
    nn.Flatten(),
    nn.Linear(400, 120), nn.ReLU(),
    nn.Linear(120, 84), nn.ReLU(),
    nn.Linear(84, 10),
)
print(sum(p.numel() for p in lenet.parameters()))       # 61706


class BasicBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.conv2 = nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.shortcut = nn.Identity()
        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels, 1, stride, bias=False),
                nn.BatchNorm2d(out_channels),
            )

    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        return F.relu(out + self.shortcut(x))


block = BasicBlock(64, 128, stride=2)
print(block(torch.zeros(1, 64, 56, 56)).shape)          # torch.Size([1, 128, 28, 28])

Stacking a 7 × 7 stride-2 stem, a 3 × 3 stride-2 max pool, four stages of two such blocks (64, 128, 256 and 512 channels), global average pooling and a 512 → 1000 linear layer gives exactly the 11,689,512 parameters in the table. Real libraries compute convolutions with im2col plus a matrix multiply, Winograd or FFT transforms, or cuDNN kernels picked per shape; the arithmetic is the same.

Tradeoffs

  • Inductive bias. Locality and weight sharing make CNNs data-efficient and fast, but they also limit what a layer can relate: two distant pixels only interact many layers up. Vision transformers drop the bias, need more data or pretraining, and win at scale.
  • Downsampling. Stride and pooling grow the receptive field cheaply and cut compute, but throw away exact position. Dense prediction (segmentation, detection) adds it back with skip connections, dilation or upsampling.
  • Kernel size. Stacked 3 × 3 kernels give the same field as one large kernel with fewer parameters and more nonlinearity; recent ConvNets bring back 7 × 7 depthwise kernels because they are cheap when depthwise.
  • Padding keeps sizes tidy but tells the network where the border is, which slightly breaks translation equivariance.
  • Stride versus pooling. A strided convolution learns how to downsample; max pooling is free and adds a little shift invariance. Modern networks mostly use strided convolutions after the stem.
  • Batch norm in residual blocks makes deep CNNs trainable at high learning rates but ties the output to batch statistics; small-batch training often switches to group norm.

Check yourself

What is the output size of Conv2d(3, 64, kernel_size=7, stride=2, padding=3) on a 224 × 224 image, and how many parameters does it have?

⌊(224 + 6 − 6 − 1)/2⌋ + 1 = 112, so [N, 64, 112, 112]. Parameters: 64 × 3 × 7 × 7 + 64 = 9,472 (ResNet's stem has bias=False, so 9,408).

Why does a 1 × 1 convolution have parameters at all, if it looks at one pixel?

It mixes channels: each output channel is a weighted sum of all Cin input channels at that pixel, with Cout × Cin weights. It is a linear layer applied at every position.

Two stacked 3 × 3 convolutions with stride 1: what is the receptive field? And if the first has stride 2?

Stride 1: 1 + 2 + 2 = 5. With stride 2 first: the first adds 2 (r = 3, j = 2), the second adds 2 × 2 = 4, so r = 7.

Why can't the second block of a ResNet stage just add x to F(x) in the first block of that stage?

In the first block the main path halves the resolution and doubles the channels, so F(x) and x have different shapes. The 1 × 1 stride-2 shortcut maps x to the new shape. Later blocks keep the shape and add x directly.

Connections

Read first

Read next

References

  • LeCun, Bottou, Bengio and Haffner. Gradient-Based Learning Applied to Document Recognition. Proceedings of the IEEE, 1998 (LeNet-5).
  • Krizhevsky, Sutskever and Hinton. ImageNet Classification with Deep Convolutional Neural Networks. NeurIPS, 2012 (AlexNet).
  • Simonyan and Zisserman. Very Deep Convolutional Networks for Large-Scale Image Recognition. ICLR, 2015 (arXiv 1409.1556; stacked 3 × 3 kernels).
  • He, Zhang, Ren and Sun. Deep Residual Learning for Image Recognition. CVPR, 2016 (arXiv 1512.03385).
  • Dumoulin and Visin. A guide to convolution arithmetic for deep learning. 2016 (arXiv 1603.07285).
  • Luo, Li, Urtasun and Zemel. Understanding the Effective Receptive Field in Deep Convolutional Neural Networks. NeurIPS, 2016 (arXiv 1701.04128).
  • PyTorch v2.15.0: torch/nn/modules/conv.py (_ConvNd), Conv2d, torch/nn/modules/pooling.py.