Convolutional networks

A convolution is a small dot product slid across a grid, and that one design choice, the same weights applied at every position, is a statement about the world: useful patterns are local and can appear anywhere. This page works the output-size arithmetic numerically, traces receptive fields through a real network, builds conv2d from scratch with im2col in both PyTorch and JAX to show that convolution is secretly a matrix multiply, and finishes with a LeNet-style classifier in each framework's idiomatic form.

What it is and when you reach for it

A convolutional layer is a linear layer with two constraints imposed before training starts: locality, each output looks only at a small neighborhood of the input, and weight sharing, the same kernel is reused at every position. Both constraints remove parameters rather than add machinery, and that is the point. A fully connected layer from a 28×28 image to a same-sized bank of 6 feature maps would need about 3.7 million weights; the convolutional version in LeNet does the same job with 156, because a 5×5 kernel per input-output channel pair is all it stores. The removed parameters encode an assumption, called an inductive bias: an edge detector useful in the top-left corner is useful everywhere, so learn it once. When that assumption matches the data, grids where nearby values are related and patterns are translation-invariant (images, spectrograms, board states, 1-D sensor streams), the network learns faster from less data than any unconstrained model could. When it does not match, say tabular columns with no spatial order, convolution buys nothing. Relative to its neighbors: the MLP is convolution with both constraints deleted, and the transformer deletes locality but keeps position-independent weights, then lets attention decide dynamically what is near what. A longer conceptual treatment lives in my notes at /ai/notes/convolutional-networks; this page is the implementation-first version.

The math

The sliding dot product

For a single channel, the output at position (i, j) is the dot product of a k×k kernel w with the input patch anchored there: out[i, j] = ΣaΣb w[a, b] · x[i·s + a, j·s + b], with stride s and the sums running over the kernel. A tiny example makes it concrete. Take a 4×4 input and the 2×2 kernel [[1, 0], [0, −1]], which computes x[i, j] − x[i+1, j+1], a crude diagonal-edge detector:

input           kernel        output (3×3, stride 1, no padding)
1 2 0 1         1  0          1−1  2−3  0−1        0 −1 −1
0 1 3 1         0 −1          0−1  1−0  3−0   =   −1  1  3
2 1 0 0                       2−0  1−1  0−2        2  0 −2
1 0 1 2

With multiple channels the dot product also runs over the input channel axis: a kernel of shape (Cin, k, k) produces one output channel, and a layer stacks Cout such kernels into a weight tensor of shape (Cout, Cin, k, k). Each output channel is one learned pattern detector evaluated everywhere. Strictly speaking this operation is cross-correlation, not the signal-processing convolution, which would flip the kernel first; deep learning frameworks skip the flip because a learned kernel can just as easily learn the flipped weights. The name stuck anyway.

Output-size arithmetic, worked numerically

For input size n, kernel k, padding p, and stride s, the output size along each spatial axis is

out = ⌊(n + 2p − k) / s⌋ + 1

The formula counts anchor positions: the padded input has n + 2p pixels, the last anchor must leave room for the kernel (subtract k, then the +1 restores the first position), and stride s keeps every s-th anchor. Worked through the numbers this page actually uses: a 28×28 digit through a 5×5 kernel with p = 2, s = 1 gives (28 + 4 − 5)/1 + 1 = 28, size preserved, which is what "same" padding means and why p = (k−1)/2 is the magic value for odd k. A 2×2 max pool with stride 2 gives (28 − 2)/2 + 1 = 14. The second conv, 5×5 with no padding, gives (14 − 5)/1 + 1 = 10, and the second pool (10 − 2)/2 + 1 = 5. One stride-2 example from a real stem: ResNet's first layer is a 7×7 kernel, p = 3, s = 2 on a 224×224 image, so ⌊(224 + 6 − 7)/2⌋ + 1 = ⌊223/2⌋ + 1 = 112, and the floor is doing real work there. Getting this arithmetic wrong is the single most common way a hand-built network fails to run, so it is worth being able to do it in your head.

Receptive fields

A unit deep in the network depends on a patch of the original input called its receptive field, and the patch grows layer by layer according to rl = rl−1 + (kl − 1) · jl−1, where j is the jump, the product of all strides so far. Tracing LeNet: after conv 5×5 (r = 5, j = 1), pool 2×2 stride 2 (r = 6, j = 2), conv 5×5 (r = 6 + 4·2 = 14, j = 2), pool (r = 16, j = 4). So each unit in the final 5×5 feature map sees a 16×16 patch of the digit, and the fully connected head that reads all 25 positions sees everything. This is also the arithmetic behind a famous design shift: two stacked 3×3 convolutions have the same 5×5 receptive field as one 5×5 layer but cost 2·9·C² = 18C² weights instead of 25C² and insert an extra nonlinearity between them. That observation is essentially the VGG paper, and it is why kernels larger than 3×3 became rare for a decade. One honest caveat: the effective receptive field, where the gradient actually concentrates, is measurably smaller than this theoretical box and roughly Gaussian around the center, so treat the formula as an upper bound.

Pooling and batch norm

Pooling summarizes each small window by one number, usually the max, halving resolution while keeping the strongest response. It buys a little local translation invariance (a feature that shifts by a pixel usually still wins its window) and, more importantly, it lets the layers above cover more of the image with the same kernel size, which is exactly the jump term in the receptive-field recursion. Modern networks often replace pooling with stride-2 convolutions, which do the same downsampling with learned weights; the arithmetic is identical.

Batch normalization, at the concept level, standardizes each channel using the statistics of the current batch: for channel c, compute the mean and variance over all samples and all spatial positions, normalize to zero mean and unit variance, then apply a learned per-channel scale γ and shift β so the network keeps its expressive range. Normalizing per channel rather than per unit is forced by weight sharing: a channel is one feature evaluated everywhere, so it gets one set of statistics. The practical payoff is that activations stay in a healthy range regardless of depth, which permits higher learning rates and makes deep stacks trainable. The practical trap is that it behaves differently at training time (batch statistics) and at inference time (running averages accumulated during training), which is exactly what the train/eval mode switch in every framework controls.

Implementation, twice

conv2d from scratch: convolution is a matmul wearing a coat

The classic way to implement convolution, and still one of the ways production backends do it, is im2col: copy every k×k×C receptive field out into a column of a matrix, so the whole convolution collapses into a single matrix multiply between the flattened kernels and that column matrix. It spends memory (each pixel is duplicated up to k² times) to buy the one operation GPUs are best at. The PyTorch tab uses F.unfold, which is im2col by its framework name; the JAX tab shows the production path, lax.conv_general_dilated, the primitive underneath every JAX conv layer, plus an educational im2col built from pad, slice, and einsum. Both tabs end by asserting agreement with the framework's own convolution on a fixed seed, which is the check that makes the exercise trustworthy.

import torch
import torch.nn.functional as F

def conv2d_im2col(x, w, b=None, stride=1, padding=0):
    """x: (N, C_in, H, W), w: (C_out, C_in, kH, kW).

    F.unfold copies every receptive field into a column, ordered
    channel-major then kernel-position, which is exactly the order
    w.view(C_out, -1) flattens to, so one batched matmul finishes it.
    """
    N, C, H, W = x.shape
    C_out, _, kH, kW = w.shape
    H_out = (H + 2 * padding - kH) // stride + 1
    W_out = (W + 2 * padding - kW) // stride + 1
    cols = F.unfold(x, (kH, kW), stride=stride, padding=padding)
    #    (N, C*kH*kW, L) with L = H_out*W_out
    out = w.view(C_out, -1) @ cols          # (C_out,K) @ (N,K,L) -> (N,C_out,L)
    if b is not None:
        out = out + b.view(1, -1, 1)
    return out.view(N, C_out, H_out, W_out)

# verify against the library on a fixed seed
torch.manual_seed(0)
x = torch.randn(2, 3, 8, 8)
w = torch.randn(4, 3, 3, 3)
b = torch.randn(4)
mine = conv2d_im2col(x, w, b, stride=2, padding=1)
ref  = F.conv2d(x, w, b, stride=2, padding=1)
assert mine.shape == ref.shape == (2, 4, 4, 4)   # (8+2-3)//2+1 = 4
assert torch.allclose(mine, ref, atol=1e-5)
import jax
import jax.numpy as jnp
from jax import lax

def conv2d(x, w, stride=1, padding=0):
    """The production path: this primitive underlies every JAX conv
    layer, and XLA lowers it to the backend's native convolution."""
    return lax.conv_general_dilated(
        x, w,
        window_strides=(stride, stride),
        padding=[(padding, padding), (padding, padding)],
        dimension_numbers=("NCHW", "OIHW", "NCHW"))

def conv2d_im2col(x, w, stride=1, padding=0):
    """Educational im2col: same answer from pad + slice + einsum.

    The loops run over kernel offsets (k*k iterations, tiny and
    unrolled under jit), never over image positions, so every slice
    below is a full strided view of the image: still vectorized.
    """
    N, C, H, W = x.shape
    C_out, _, kH, kW = w.shape
    H_out = (H + 2 * padding - kH) // stride + 1
    W_out = (W + 2 * padding - kW) // stride + 1
    xp = jnp.pad(x, ((0, 0), (0, 0), (padding,) * 2, (padding,) * 2))
    patches = jnp.stack(
        [xp[:, :, i:i + stride * H_out:stride, j:j + stride * W_out:stride]
         for i in range(kH) for j in range(kW)],
        axis=2)                                   # (N, C, kH*kW, H_out, W_out)
    cols = patches.reshape(N, C * kH * kW, H_out * W_out)
    out = jnp.einsum("ok,nkl->nol", w.reshape(C_out, -1), cols)
    return out.reshape(N, C_out, H_out, W_out)

# verify one against the other on a fixed key
k1, k2 = jax.random.split(jax.random.PRNGKey(0))
x = jax.random.normal(k1, (2, 3, 8, 8))
w = jax.random.normal(k2, (4, 3, 3, 3))
a = conv2d(x, w, stride=2, padding=1)
b = conv2d_im2col(x, w, stride=2, padding=1)
assert a.shape == (2, 4, 4, 4)                   # (8+2-3)//2+1 = 4
assert jnp.allclose(a, b, atol=1e-4)

The im2col versions are forward-only on purpose: the backward pass through convolution is itself a convolution (with the kernel spatially flipped, against the output gradient), and autograd derives it for free through unfold and einsum, which you can confirm by calling grad through either function.

A LeNet-style classifier, idiomatically

The second block is the network that started the field: two conv-pool stages that shrink 28×28 down to a 16-channel 5×5 map, then an MLP head that reads it, about 60k parameters in total. The shape pipeline, with the arithmetic from above annotated:

input 1×28×28
  │ conv 5×5, pad 2, 6 filters      →  6×28×28    (28+4−5)/1+1 = 28
  │ ReLU, maxpool 2×2 s2            →  6×14×14    (28−2)/2+1  = 14
  │ conv 5×5, 16 filters            → 16×10×10    (14−5)/1+1  = 10
  │ ReLU, maxpool 2×2 s2            → 16×5×5      (10−2)/2+1  = 5
  │ flatten                         → 400
  │ dense 120 → dense 84 → dense 10 → logits

For the JAX tab I chose flax (Linen) over a raw params pytree, and the reason is where the teaching value lives. The neural network page already builds and threads a pytree by hand, and for an MLP that is instructive; for a CNN the pytree stops teaching and starts being bookkeeping, since every conv layer carries a differently shaped kernel and the natural next step, batch norm, adds mutable running statistics that plain jax.grad has no slot for. Flax's Module handles initialization from a single RNG key, nests the parameters with readable names, and has a first-class place for that mutable state, while everything still compiles down to the same lax conv primitive shown above.

import torch
from torch import nn

class LeNet(nn.Module):
    """LeNet-5 shaped, for 1×28×28 inputs. NCHW, PyTorch's native layout."""
    def __init__(self, num_classes=10):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 6, 5, padding=2), nn.ReLU(),   # 28 -> 28
            nn.MaxPool2d(2),                            # 28 -> 14
            nn.Conv2d(6, 16, 5), nn.ReLU(),             # 14 -> 10
            nn.MaxPool2d(2),                            # 10 -> 5
        )
        self.head = nn.Sequential(
            nn.Flatten(),
            nn.Linear(16 * 5 * 5, 120), nn.ReLU(),
            nn.Linear(120, 84), nn.ReLU(),
            nn.Linear(84, num_classes),                 # raw logits
        )

    def forward(self, x):
        return self.head(self.features(x))

model = LeNet()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)

def train_step(x, y):
    opt.zero_grad()
    loss = nn.functional.cross_entropy(model(x), y)
    loss.backward()
    opt.step()
    return loss.item()

# smoke test on a fake batch
x, y = torch.randn(64, 1, 28, 28), torch.randint(0, 10, (64,))
print(train_step(x, y))   # ~2.3 = ln(10): uniform over 10 classes
import jax
import jax.numpy as jnp
import flax.linen as nn
import optax

class LeNet(nn.Module):
    """LeNet-5 shaped, for 28×28×1 inputs. NHWC, flax's native layout."""
    num_classes: int = 10

    @nn.compact
    def __call__(self, x):
        x = nn.Conv(6, (5, 5), padding="SAME")(x)          # 28 -> 28
        x = nn.relu(x)
        x = nn.max_pool(x, (2, 2), strides=(2, 2))         # 28 -> 14
        x = nn.Conv(16, (5, 5), padding="VALID")(x)        # 14 -> 10
        x = nn.relu(x)
        x = nn.max_pool(x, (2, 2), strides=(2, 2))         # 10 -> 5
        x = x.reshape(x.shape[0], -1)                      # 400
        x = nn.relu(nn.Dense(120)(x))
        x = nn.relu(nn.Dense(84)(x))
        return nn.Dense(self.num_classes)(x)               # raw logits

model = LeNet()
params = model.init(jax.random.PRNGKey(0), jnp.zeros((1, 28, 28, 1)))
tx = optax.adam(1e-3)
opt_state = tx.init(params)

def loss_fn(params, x, y):
    logits = model.apply(params, x)
    return optax.softmax_cross_entropy_with_integer_labels(logits, y).mean()

@jax.jit
def train_step(params, opt_state, x, y):
    loss, grads = jax.value_and_grad(loss_fn)(params, x, y)
    updates, opt_state = tx.update(grads, opt_state)
    return optax.apply_updates(params, updates), opt_state, loss

# smoke test on a fake batch
k1, k2 = jax.random.split(jax.random.PRNGKey(1))
x = jax.random.normal(k1, (64, 28, 28, 1))
y = jax.random.randint(k2, (64,), 0, 10)
params, opt_state, loss = train_step(params, opt_state, x, y)
print(loss)               # near ln(10) ≈ 2.3: still guessing over 10 classes

Using it on a real shape of problem

Point either training step at MNIST (60,000 grayscale 28×28 digits, batches of 64, a couple of epochs) and the behavior is textbook. The first printed loss sits near ln 10 ≈ 2.30, the entropy of guessing uniformly over ten classes; within a few hundred steps it is under 0.5, and after one epoch a LeNet at this scale reaches roughly 98% test accuracy, with 99% in reach after a few more, exact numbers varying with seed and machine. Two diagnostics are worth building the habit of checking. First, that initial loss: if it is not close to ln(num_classes), the head or the loss wiring is wrong before training has anything to do with it. Second, overfit a single batch: any correct CNN of this size should drive 64 samples to essentially zero loss within a few hundred steps, and failure to do so means a bug, not a capacity problem. The same architecture with the first conv widened to 3 input channels trains on CIFAR-10 unchanged, though it plateaus far lower there, which is the historically accurate signal that depth, normalization, and residual connections had to be invented.

Applications

The convolutional lineage is the spine of computer vision. LeNet read a meaningful share of US bank checks in the late 1990s; AlexNet's 2012 ImageNet win is the event that made deep learning mainstream; VGG established the all-3×3 design; ResNet's residual connections made 100-plus-layer networks trainable and remain the default baseline backbone. On top of classification backbones sit the detection family, Faster R-CNN and the YOLO line, still dominant wherever real-time boxes matter, and segmentation, where U-Net's encoder-decoder with skip connections is so entrenched in medical imaging that entire frameworks exist just to serve variants of it. Convolutions also read audio: a spectrogram is an image whose axes are time and frequency, and conv frontends power keyword spotting on phones, speaker ID, and the input stem of Whisper, which downsamples the mel spectrogram with two stride-2 convolutions before any transformer layer sees it.

In the transformer era convs did not disappear; they moved into the hybrids. Vision transformers commonly use convolutional stems or patch-embedding convs; ConvNeXt showed that a modernized pure-conv network matches same-scale ViTs; MobileNet and EfficientNet variants still own on-device inference because depthwise convolutions are cheap in exactly the way mobile hardware likes; and the U-Net inside Stable Diffusion's original architecture interleaves convolutional residual blocks with attention. The bias survives because it is true: at the lowest layers of vision, features really are local and translation-invariant, and paying attention's quadratic price to rediscover that is wasteful.

Against the real libraries

The im2col conv above is honest about the algorithm and silent about everything a production stack adds. Backends like cuDNN choose among several algorithms per layer shape (im2col-style GEMM, FFT for large kernels, Winograd for 3×3, direct kernels), use tensor cores and fused epilogues, and never materialize the k²-duplicated column matrix the naive version pays for. torchvision supplies the canonical model implementations (ResNet, MobileNet, Faster R-CNN, and the rest) with pretrained ImageNet weights, the dataset and transform plumbing, and reference training recipes whose accuracy numbers are documented per model. timm is the model zoo of record beyond that: over a thousand architectures behind one create_model interface, with pretrained weights and the training tricks (augmentation recipes, EMA, schedulers) that reproduce published results, which is why nearly every vision paper's baseline table is built on it. On the JAX side, the flax repository's examples directory contains the reference MNIST and ImageNet training pipelines that most JAX vision code descends from. My notes on the PyTorch codebase itself are at /oss/pytorch.

When is from-scratch enough? For understanding, always; for production, essentially never, and the line is sharper here than for the MLP because convolution performance is dominated by algorithm selection and kernel fusion you cannot replicate in a few lines. The from-scratch version earns its keep as a verification oracle and as the mental model that explains behavior: why channels-last memory layout speeds up training, why a 1×1 conv is just a per-pixel linear layer, why grouped and depthwise convs are cheap. The verification recipe is in the code above: fixed seed, random x and w, compare your implementation against F.conv2d or lax.conv_general_dilated with allclose at about 1e-5 in float32, and include a strided, padded case, because stride-1 no-padding agreement catches almost nothing. If your conv matches the library on a padded stride-2 case with non-square everything, your indexing is right; that single test retires the whole class of off-by-one bugs.

Traps and misconceptions

"Convolution" is cross-correlation. Frameworks do not flip the kernel, so a hand-derived signal-processing result can disagree with conv2d until you notice. For learning it is irrelevant, the network learns flipped weights if it needs them, but it matters whenever you port classical filters or compare against scipy, whose convolve2d does flip.

Trusting "same" padding without doing the arithmetic. Same padding preserves size only at stride 1. At stride 2 a "same"-padded 28 input gives 14, and for even inputs the padding is asymmetric, so different frameworks can place the extra pixel on different sides and produce subtly different outputs from identical weights. Porting bugs between PyTorch (explicit p) and TensorFlow or flax ("SAME" strings) usually live exactly here.

Forgetting eval mode with batch norm. In train mode batch norm uses current-batch statistics; forget model.eval() and inference results depend on batch composition, including the degenerate single-sample case where batch variance is meaningless. The symptom is a model that "works in the notebook, fails in the service." The same switch controls dropout, so the habit pays twice.

Confusing theoretical with effective receptive field. The recursion in the math section gives the maximum patch a unit can see; the gradient-weighted effective field is much smaller and Gaussian-shaped, so a network whose theoretical field covers the image can still fail on genuinely global structure. When objects are large relative to the field that actually matters, the fixes are depth, dilation, or attention, not hope.

Layout mixups between NCHW and NHWC. PyTorch defaults to channels-first, flax and TensorFlow to channels-last, and a tensor reshaped across that boundary rather than transposed produces scrambled images that still have the right shape, so nothing crashes: the model just trains badly. When porting weights, permute axes explicitly and verify with a fixed-input forward-pass comparison before training anything.

Key takeaway: a convolutional layer is a linear map with locality and weight sharing imposed as an inductive bias, im2col shows it is literally a matrix multiply over unrolled patches, and the two formulas worth memorizing, out = ⌊(n + 2p − k)/s⌋ + 1 and rl = rl−1 + (k − 1)·jl−1, let you size and reason about any architecture in this family before writing a line of code. The bias earned its permanence: even attention-first models keep convolutions wherever the data really is a grid.