The Convolution Operation

Imagine feeding a 224×224×3 image into a fully connected layer. Even a modest 1000 hidden units causes problems. That single layer needs over 150 million weights. It also has no idea that a cat's ear looks the same in the top-left or bottom-right of the photo. The convolution operation solves both problems at once. It replaces a dense matrix multiply with a small, reusable filter. This filter slides across the input, detecting the same local pattern wherever it occurs. This idea is the computational core of every Convolutional Neural Network. It is used in image recognition, medical imaging, audio processing, and more. It even appears in parts of modern language models that use convolutional front-ends. It matters because it encodes two extremely useful priors about the world directly into the architecture: locality (nearby pixels are more related than distant ones) and translation equivariance (a pattern is a pattern no matter where it sits). These priors are not learned from data. They are built into the operation itself. This is why convolutional networks generalize so well from comparatively little data. The big picture is simple once you see it. Convolution is just a highly structured, weight-shared linear operation. It is followed, as in a normal Feedforward Network layer, by a nonlinearity. What makes it subtle is the bookkeeping. Kernel size, stride, and padding interact to determine output shape. Also, "convolution" in deep learning almost always secretly means cross-correlation. This article builds the operation from first principles. It covers the exact arithmetic, the sliding-window algorithm, and the shape formulas. It also explains why this restricted linear map is both correct and dramatically more efficient than a dense layer. By the end, you will be able to compute output dimensions by hand. You will be able to trace a full 2D convolution numerically. You will be able to explain precisely how convolution differs from true mathematical convolution. And you will see why this single operation sets up everything that follows. This includes parameter sharing across channels and Pooling Operations for spatial downsampling.

Key takeaways
  • Convolution slides a small kernel over an input, computing a weighted sum (dot product) at every position.
  • Deep learning "convolution" is technically cross-correlation — no kernel flipping — but the name stuck.
  • Three hyperparameters control geometry: kernel size, stride, and padding, all linked by one output-size formula.
  • Weight sharing means one kernel has far fewer parameters than a dense layer, independent of image size.
  • Complexity is `O(output_size × kernel_size × channels)` per output map, far below `O(input_size × output_size)` for a dense layer.
  • The operation is linear and equivariant to translation, which is what makes learned filters reusable across the image.
The core insight

Convolution replaces a full weight matrix with one small, position-shared kernel, trading unnecessary parameters for a built-in assumption of locality and translation invariance.

The idea

A convolution in deep learning is an operation that takes an input tensor (e.g., an image) and a small learnable tensor called a kernel or filter, and produces an output tensor by sliding the kernel across the input and computing a dot product at each position. Formally, for a 2D single-channel input II and kernel KK of size kh×kwk_h \times k_w, the output at position (i,j)(i, j) is

S(i,j)=∑m=0kh−1∑n=0kw−1I(i+m,j+n) K(m,n).S(i, j) = \sum_{m=0}^{k_h - 1} \sum_{n=0}^{k_w - 1} I(i + m, j + n) \, K(m, n).

This is precisely cross-correlation, not mathematical convolution. True convolution, as defined in signal processing, flips the kernel before sliding:

(I∗K)(i,j)=∑m,nI(i−m,j−n) K(m,n).(I * K)(i,j) = \sum_{m,n} I(i - m, j - n)\, K(m, n).

Because the kernel weights are learned rather than hand-designed, flipping makes no practical difference — a learned flipped kernel is just as good as a learned unflipped one, and the network cannot tell the difference during training. Nearly every deep learning framework implements cross-correlation and calls it "convolution"; this article follows that convention but flags the distinction because it matters when porting classical signal-processing kernels (e.g., a hand-designed edge detector) into a network. Three hyperparameters govern the geometry of the sliding window:

  • Kernel size kk — the spatial extent of the filter (e.g., 3×3, 5×5). Larger kernels see more context per step but cost more computation and parameters.
  • Stride ss — how many positions the kernel moves between computations. Stride 1 visits every position; stride 2 skips every other one, halving each spatial dimension.
  • Padding pp — how many border pixels (typically zeros) are added around the input before sliding, used to control whether the output shrinks, stays the same size ("same" padding), or shrinks freely ("valid" padding).

A real convolutional layer typically has multiple input channels (e.g., R, G, B) and multiple output channels (each its own learned filter bank), which we develop below. The key structural fact that distinguishes convolution from a dense layer is weight sharing: the same k×kk \times k set of weights is reused at every spatial location, rather than learning an independent weight for every (input pixel, output pixel) pair.

How it works

Step 1 — Choose geometry. Fix input size H×WH \times W, kernel size kh×kwk_h \times k_w, stride ss, and padding pp (assume symmetric padding on all sides for simplicity).

Step 2 — Pad the input. Create a padded input of size (H+2p)×(W+2p)(H + 2p) \times (W + 2p) by surrounding the original with pp rows/columns of zeros (zero-padding is standard; reflection and replication padding are used for specialized cases).

Step 3 — Slide and dot-product. Position the kernel at the top-left corner of the padded input. At each valid position, compute the elementwise product of the kernel and the overlapping input patch, sum the results into a single scalar, and write it to the output. Move the kernel by `stride` positions horizontally; when the row is exhausted, reset horizontally and move down by `stride` vertically.

Step 4 — Repeat across channels. For multi-channel input (depth CinC_{in}), the kernel itself has depth CinC_{in}; the dot product sums over height, width, and channel simultaneously, producing a single 2D output map per filter. Using CoutC_{out} independently learned filters produces CoutC_{out} output channels, stacked into a 3D output tensor.

Step 5 — Add bias and apply nonlinearity. Each output channel typically has one shared bias added to every spatial position, followed by an activation function (ReLU, etc.), exactly as in a standard feedforward layer. ``` function CONV2D(Input, Kernel, stride, padding):

# Input: H x W x C_in tensor

# Kernel: k_h x k_w x C_in x C_out tensor (C_out filters, each C_in-deep)

# stride: integer step size

# padding: integer border width (zero-padded) PaddedInput = ZERO_PAD(Input, padding) # (H+2p) x (W+2p) x C_in H_pad, W_pad, C_in = shape(PaddedInput) k_h, k_w, _, C_out = shape(Kernel) H_out = floor((H_pad - k_h) / stride) + 1 W_out = floor((W_pad - k_w) / stride) + 1 Output = new tensor of shape H_out x W_out x C_out, filled with 0 for f in 1 .. C_out: # each output filter for i in 0 .. H_out - 1: # output row for j in 0 .. W_out - 1: # output column row_start = i stride col_start = j stride acc = 0 for m in 0 .. k_h - 1: for n in 0 .. k_w - 1: for c in 0 .. C_in - 1: acc += PaddedInput[row_start + m, col_start + n, c]

  • Kernel[m, n, c, f]

Output[i, j, f] = acc + Bias[f] return Output ``` The four nested loops over `(m, n, c)` compute exactly the dot product from the definition of S(i,j)S(i,j) above; the two outer loops over `(i, j)` implement the sliding window; the loop over `f` implements multiple output channels. In practice this quadruple loop is never executed naively — it is reformulated as a single large matrix multiplication (the "im2col" trick: unroll every patch into a row of a matrix, then multiply by the flattened kernel matrix) to exploit highly optimized BLAS/GPU kernels, but the semantics are identical to the pseudocode above.

Figure
Sliding a 3×3 kernel over a padded 5×5 input with stride 1
Padded input (7×7, pad=1 around 5×5) kernel stride 1 Output (5×5, stride 1) S(0,0) dot product → 1 value Each red-outlined 3×3 patch maps to exactly one green output cell.

Complexity & correctness

Time complexity. For a single output channel, the algorithm visits Hout×WoutH_{out} \times W_{out} output positions, and at each position performs kh×kw×Cink_h \times k_w \times C_{in} multiply-adds. With CoutC_{out} output filters, total work is

O(Hout⋅Wout⋅kh⋅kw⋅Cin⋅Cout).O\big(H_{out} \cdot W_{out} \cdot k_h \cdot k_w \cdot C_{in} \cdot C_{out}\big).

This is honest as-is: it is the exact operation count of the naive sliding-window algorithm, matching the four nested loops in the pseudocode with no hidden shortcuts. Compare this to a fully connected layer mapping the same input (H⋅W⋅CinH\cdot W \cdot C_{in} values) to the same output size (Hout⋅Wout⋅CoutH_{out}\cdot W_{out}\cdot C_{out} values): a dense layer costs O(H⋅W⋅Cin⋅Hout⋅Wout⋅Cout)O(H\cdot W\cdot C_{in}\cdot H_{out}\cdot W_{out}\cdot C_{out}) — every input connects to every output. Convolution replaces the two full spatial factors H⋅WH\cdot W with just kh⋅kwk_h \cdot k_w, which is normally a tiny constant (3×3, 5×5, 7×7) compared to typical image dimensions (224×224). This is the entire efficiency argument in one line: locality shrinks the receptive field per output unit from "the whole image" to "a small window," and weight sharing means that shrinkage doesn't cost accuracy for the many vision tasks where patterns are local and translation-invariant.

Space complexity. The kernel itself stores kh⋅kw⋅Cin⋅Coutk_h \cdot k_w \cdot C_{in} \cdot C_{out} parameters, independent of input spatial size H,WH, W — this is the crucial parameter-count win over dense layers, whose weight count scales with H⋅WH\cdot W. The output tensor requires O(Hout⋅Wout⋅Cout)O(H_{out}\cdot W_{out}\cdot C_{out}) space, and the padded input requires O((H+2p)(W+2p)Cin)O((H+2p)(W+2p)C_{in}) space; both are unavoidable since the algorithm must materialize its output and (if padding is used) a padded copy of the input (or equivalent boundary-checking logic, which trades a little time for saving this space).

Output-size formula (correctness of the shape bookkeeping). With input size HH, padding pp on each side, kernel size kk, and stride ss:

Hout=⌊H+2p−ks⌋+1.H_{out} = \left\lfloor \frac{H + 2p - k}{s} \right\rfloor + 1.

Justification: after padding, the input has H+2pH + 2p positions along that axis. The kernel's top edge can range over any starting index ii such that the kernel (spanning kk cells) still fits: i+k−1≤H+2p−1i + k - 1 \le H + 2p - 1, i.e. i≤H+2p−ki \le H + 2p - k. Valid starting indices under stride ss are 0,s,2s,…0, s, 2s, \dots, and the count of such indices in [0,H+2p−k][0, H+2p-k] is ⌊(H+2p−k)/s⌋+1\lfloor (H+2p-k)/s \rfloor + 1. The same formula applies independently to the width axis.

Correctness of the sliding-window semantics. The claim to verify is that the pseudocode's `Output[i,j,f]` equals the mathematical definition Sf(i,j)=∑m,n,cIpad(is+m, js+n, c) K(m,n,c,f)+bfS_f(i,j) = \sum_{m,n,c} I_{pad}(i s + m,\, j s + n,\, c)\, K(m,n,c,f) + b_f. This holds by direct correspondence: the loop sets `row_start = istride`, `col_start = jstride`, then accumulates exactly the terms Ipad(row_start+m, col_start+n, c)⋅K(m,n,c,f)I_{pad}(row\_start+m,\ col\_start+n,\ c)\cdot K(m,n,c,f) for all valid (m,n,c)(m,n,c) — which is precisely the sum in the definition. The loop bounds `H_out, W_out` are derived directly from the formula above, guaranteeing every kernel placement stays within the padded input (no out-of-bounds access) and no valid placement is skipped (every ii from 00 to Hout−1H_{out}-1 corresponds to a distinct valid `row_start`). Hence the algorithm computes exactly the defined operation — an exhaustive, not approximate, computation.

Translation equivariance (why weight sharing is mathematically sound). If the input is shifted by (Δi,Δj)(\Delta i, \Delta j) (ignoring boundary effects), the output shifts by the same amount: S(i+Δi,j+Δj)S(i + \Delta i, j + \Delta j) computed on shifted input equals S(i,j)S(i,j) on the original, because the kernel weights K(m,n,c,f)K(m,n,c,f) do not depend on position (i,j)(i,j) — they are the same numbers reused at every location. This is precisely what makes a filter trained to detect, say, a vertical edge near the top of an image equally useful for detecting a vertical edge near the bottom, without needing separate parameters for each location.

Tradeoffs, stated honestly. Convolution's efficiency comes at the cost of an inductive bias: it assumes local, translation-invariant structure is useful, which is excellent for natural images but can be a mismatch for data with no meaningful spatial locality (e.g., arbitrarily permuted tabular features). Large kernels or many layers are still needed to grow the receptive field to see global structure, which is why deep stacks of small convolutions (rather than one huge kernel) became the standard design.

Worked example

Take a single-channel 5×55 \times 5 input, padding p=0p = 0, stride s=2s = 2, and a 3×33\times3 kernel — a genuinely asymmetric example (no symmetric trick hides an arithmetic slip). Input II:

I=(1201341201031242041012032)K=(10−110−110−1)(a vertical-edge detector, bias b=0)I = \begin{pmatrix} 1 & 2 & 0 & 1 & 3\\ 4 & 1 & 2 & 0 & 1\\ 0 & 3 & 1 & 2 & 4\\ 2 & 0 & 4 & 1 & 0\\ 1 & 2 & 0 & 3 & 2 \end{pmatrix} \qquad K = \begin{pmatrix} 1 & 0 & -1\\ 1 & 0 & -1\\ 1 & 0 & -1 \end{pmatrix} \quad (\text{a vertical-edge detector, bias } b=0)

Output size: Hout=⌊(5+0−3)/2⌋+1=⌊1⌋+1=2H_{out} = \lfloor (5 + 0 - 3)/2 \rfloor + 1 = \lfloor 1 \rfloor + 1 = 2. So the output is 2×22\times2. Valid starting rows/cols under stride 2 are {0,2}\{0, 2\} (since H+2p−k=2H + 2p - k = 2, indices 00 and 22 fit; index 44 would need the kernel to reach row 6, out of bounds).

Position (0,0) — patch = rows 0–2, cols 0–2:

(120412031)\begin{pmatrix}1&2&0\\4&1&2\\0&3&1\end{pmatrix}
S(0,0)=(1⋅1+2⋅0+0⋅(−1))+(4⋅1+1⋅0+2⋅(−1))+(0⋅1+3⋅0+1⋅(−1))S(0,0) = (1\cdot1 + 2\cdot0 + 0\cdot(-1)) + (4\cdot1+1\cdot0+2\cdot(-1)) + (0\cdot1+3\cdot0+1\cdot(-1))
=(1+0+0)+(4+0−2)+(0+0−1)=1+2−1=2= (1 + 0 + 0) + (4 + 0 -2) + (0+0-1) = 1 + 2 - 1 = 2

Position (0,1) — patch = rows 0–2, cols 2–4:

(013201124)\begin{pmatrix}0&1&3\\2&0&1\\1&2&4\end{pmatrix}
S(0,1)=(0−3)+(2−1)+(1−4)=−3+1−3=−5S(0,1) = (0-3) + (2-1) + (1-4) = -3 + 1 - 3 = -5

Position (1,0) — patch = rows 2–4, cols 0–2:

(031204120)\begin{pmatrix}0&3&1\\2&0&4\\1&2&0\end{pmatrix}
S(1,0)=(0−1)+(2−4)+(1−0)=−1−2+1=−2S(1,0) = (0-1) + (2-4) + (1-0) = -1 -2 +1 = -2

Position (1,1) — patch = rows 2–4, cols 2–4:

(124410032)\begin{pmatrix}1&2&4\\4&1&0\\0&3&2\end{pmatrix}
S(1,1)=(1−4)+(4−0)+(0−2)=−3+4−2=−1S(1,1) = (1-4) + (4-0) + (0-2) = -3 + 4 -2 = -1

Final output:

S=(2−5−2−1)S = \begin{pmatrix} 2 & -5 \\ -2 & -1 \end{pmatrix}
Output positionPatch rowsPatch colsComputed value
(0,0)0–20–22
(0,1)0–22–4−5
(1,0)2–40–2−2
(1,1)2–42–4−1

This traces exactly one full forward pass: notice stride 2 means the patches at row 0–2 and row 2–4 do not overlap in the row dimension but do overlap in column selection with each other's neighbors, a subtlety stride-1 examples never expose. If instead you used padding p=1p=1, the formula gives Hout=⌊(5+2−3)/2⌋+1=2+1=3H_{out} = \lfloor(5+2-3)/2\rfloor+1 = 2 + 1= 3 (recomputing with the zero border), producing a 3×33\times 3 output — a good self-check exercise.

Common pitfalls & traps

PitfallWhy it happensFix
Off-by-one in output sizeForgetting the `+1` or using ⌈⋅⌉\lceil \cdot \rceil instead of ⌊⋅⌋\lfloor \cdot \rfloorAlways use ⌊(H+2p−k)/s⌋+1\lfloor (H+2p-k)/s \rfloor + 1 and verify with a small hand example
Confusing convolution with cross-correlationAssuming kernel is flipped, as in classic signal processing textsRemember: deep learning frameworks by default do not flip the kernel
Ignoring channel dimension in the dot productTreating a multi-channel kernel as if it only spans height/widthThe kernel depth must equal input channel depth; the sum runs over `(m, n, c)` jointly
Wrong padding assumptionBelieving "same" padding always keeps output size identical for any stride"Same" padding preserves size only when stride = 1; larger strides always shrink the output regardless of padding
Using a kernel larger than the (padded) inputPassing `padding=0` with a kernel bigger than the inputOutput size formula gives a negative or zero dimension — an invalid configuration; always check H+2p≥kH + 2p \ge k
Reusing the pseudocode's naive quadruple loop for real workloadsAssuming this is how production frameworks execute convolutionReal systems reformulate as im2col + matrix multiply or FFT-based methods for speed; the naive loop is for correctness reasoning only

Pioneers & history

The convolution operation as used in neural networks descends from a lineage joining signal processing, neuroscience-inspired vision models, and gradient-based learning. Its neural-network incarnation is most closely associated with Yann LeCun, who combined convolutional weight sharing with backpropagation to build practical, trainable vision systems.

Origins · 1 minYann LeCun and how this was discovered
YL
Yann LeCun
born 1960 · Developed convolutional neural networks (LeNet) trained with backpropagation for handwritten digit recognition, establishing convolution with shared weights as a practical, trainable building block for vision, later a cornerstone of the deep learning resurgence

History
1980
— Kunihiko Fukushima introduces the Neocognitron, a hierarchical, locally-connected vision model inspired by the visual cortex.
1989
— Yann LeCun applies backpropagation to a convolutional architecture for handwritten digit recognition.
1998
— LeCun et al. publish LeNet-5, a fully trained convolutional network deployed for reading checks and postal codes.
2012
— AlexNet, trained on GPUs, wins the ImageNet competition, demonstrating deep convolutional networks at scale.
2015
— Residual networks (ResNet) push convolutional depth past 100 layers, using skip connections to ease optimization.

Where this matters

Convolution is the computational backbone of image classification, object detection, semantic segmentation, and medical image analysis systems, from mobile phone camera pipelines to radiology screening tools. It underlies speech and audio models that treat spectrograms as images, and 1D convolutions are widely used for time-series and genomic sequence analysis. Modern hardware — GPUs, TPUs, and dedicated convolution accelerators — is co-designed around the im2col/matrix-multiply reformulation of exactly the sliding-window operation described here, because convolution's regular, data-parallel structure maps efficiently onto massively parallel compute. Frameworks like PyTorch, TensorFlow, and JAX expose highly tuned convolution primitives (via libraries such as cuDNN) that implement precisely the mathematics developed in this article, layered underneath the higher-level abstractions of Convolutional Layers and Parameter Sharing and followed downstream by Pooling Operations for spatial downsampling.

Ask the tutor: How does stacking multiple convolutional layers grow the network's effective receptive field, and how is that related to the design choices behind ResNet and other deep architectures?

Why is it called "convolution" if it's technically cross-correlation?

The name is a historical carryover from signal processing, where true convolution — flipping the kernel before sliding — has useful mathematical properties like commutativity and a clean connection to the Fourier transform (multiplication in frequency domain). In neural networks, the kernel weights are learned from data rather than derived analytically, so whether the kernel is flipped or not makes no difference to what the network can learn — a flipped version of a useful kernel is just as learnable as the unflipped one. Because implementing cross-correlation is simpler (no flip step) and produces identical learning outcomes, essentially every deep learning framework computes cross-correlation while retaining the traditional name "convolution."

What exactly does "padding" accomplish, and why would you ever want it?

Padding adds extra border values (usually zeros) around the input before sliding the kernel. Without padding ("valid" convolution), the output shrinks with every convolutional layer, and edge/corner pixels contribute to far fewer output computations than central pixels, biasing the network away from boundary information. "Same" padding chooses pp so that, at stride 1, the output has identical spatial size to the input, preserving resolution across many stacked layers and letting boundary pixels participate as fully as interior ones. The tradeoff is a modest amount of extra computation on the zero-valued border, which is normally negligible compared to overall layer cost.

How does stride affect the receptive field and computation cost?

Stride controls how far the kernel moves between placements: stride 1 visits every position, while stride s>1s>1 skips positions, shrinking the output by roughly a factor of ss along each spatial axis. This directly reduces the number of dot products computed, cutting time and memory for that layer close to 1/s21/s^2 in 2D. The tradeoff is a coarser output grid — fewer, more spatially separated outputs — which is why stride-2 convolutions are often used as a cheaper alternative to (or combined with) explicit pooling for downsampling.

Does the convolution operation itself learn anything, or is the learning elsewhere?

The convolution operation itself is just an execution rule — a fixed recipe for combining an input and a kernel into an output — but the kernel values K(m,n,c,f)K(m,n,c,f) and biases bfb_f are the learnable parameters, updated via gradient descent and backpropagation exactly like weights in a feedforward layer. Because these weights are shared across every spatial location, gradients from every position where a particular kernel weight was used are summed (accumulated) during backpropagation before the update is applied. This is what lets a single small filter, e.g. a 3×3 edge detector, be refined using error signals collected from every location in every training image where it was applied.

Why not just use one giant kernel the size of the whole image?

A kernel as large as the whole input would compute exactly one output value per filter, with as many weights as a fully connected layer connecting that region to that output — eliminating the parameter savings and the position-independent reuse that make convolution efficient. It would also destroy translation equivariance, since the giant kernel only ever sees the input in one fixed configuration, never sliding to detect the same pattern elsewhere. Deep learning instead stacks many small kernels (3×3, 5×5) across many layers; because receptive fields grow with depth, a handful of small-kernel layers can see as much context as one giant kernel, at far lower parameter and computation cost.

How does multi-channel convolution differ from single-channel convolution?

With CinC_{in} input channels, each filter is not a flat 2D grid but a 3D tensor of shape kh×kw×Cink_h \times k_w \times C_{in}; the dot product at each spatial position sums over height, width, and channel simultaneously, collapsing all input channels into a single scalar per output position. Using CoutC_{out} independently learned filters (each still spanning all CinC_{in} input channels) produces CoutC_{out} separate output channels, stacked into a 3D output tensor exactly as in the pseudocode's outer `f` loop. This is why a convolutional layer's parameter count scales with kh⋅kw⋅Cin⋅Coutk_h \cdot k_w \cdot C_{in} \cdot C_{out}, not merely kh⋅kwk_h \cdot k_w.

Is the naive nested-loop pseudocode actually how frameworks compute convolutions in practice?

No — the nested loops are the clearest way to state and verify the semantics of convolution, but production systems reformulate the same computation as a large matrix multiplication (the im2col technique: each sliding patch becomes a row of a matrix, multiplied by a flattened kernel matrix) or use FFT-based methods for large kernels, both of which exploit decades of optimized linear algebra and parallel hardware. These reformulations compute mathematically identical output values to the nested-loop definition; they differ only in execution strategy, not in what is being computed, which is why verifying correctness against the simple loop version remains the right mental model.

Where this leads