Convolutional networks are built from one operation repeated with different kernels, strides and dilations, and most design decisions about them come down to a single number: how many input pixels can influence one output value. This post implements the operation in NumPy, works out the receptive-field arithmetic, runs a Sobel edge detector on a synthetic image, and then does the parameter count that made VGG-style architectures abandon large kernels.

The operation

What deep-learning libraries call a convolution is, strictly, a cross-correlation: the kernel is slid over the input without being flipped. For a single-channel image x and a k×k kernel w,

y[i,j]=∑u=0k−1∑v=0k−1w[u,v]x[i⋅s+u,j⋅s+v],

with stride s and whatever zero-padding was applied to x beforehand. The output size along each axis is ⌊(n+2p−k)/s⌋+1 for input size n and padding p. A loop over the k2 kernel entries, rather than over output pixels, keeps the NumPy version short and fast:

import numpy as np

def conv2d(x, k, stride=1, padding=0):
    """Cross-correlation of a 2-D array with a 2-D kernel (what CNNs call 'convolution')."""
    if padding:
        x = np.pad(x, padding)
    H, W = x.shape
    kh, kw = k.shape
    oh, ow = (H - kh) // stride + 1, (W - kw) // stride + 1
    out = np.zeros((oh, ow))
    for i in range(kh):                      # k*k shifted, strided slices, each weighted
        for j in range(kw):
            out += k[i, j] * x[i:i + stride * oh:stride, j:j + stride * ow:stride]
    return out

For a 128×128 input and a 3×3 kernel this gives a 128×128 output with padding=1, 126×126 without, and 64×64 with stride=2, padding=1, matching the formula.

TIP

Because the kernel is not flipped, a learned CNN kernel is "the pattern the unit responds to", read directly. If you ever port a hand-designed filter from signal-processing literature, which assumes true convolution, flip it in both axes first; for symmetric kernels such as Gaussians the distinction does not matter, for Sobel-type derivative kernels it flips the sign.

Edge detection on a synthetic image

A 128×128 synthetic image with a bright square, a mid-grey disc, a thin diagonal line, a horizontal brightness ramp and a little noise is enough to see what derivative kernels do. The Sobel kernels approximate the horizontal and vertical derivatives with a bit of smoothing across the other axis:

Gx=(−101−202−101),Gy=Gx⊤.
sx = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], float)
gx = conv2d(img, sx, padding=1)
gy = conv2d(img, sx.T, padding=1)
mag = np.hypot(gx, gy)
Synthetic image and its Sobel responses
Left to right: the synthetic input, the horizontal-derivative response (vertical edges light up with a sign that encodes the direction of the step), the vertical-derivative response, and the gradient magnitude. The gentle ramp in the background produces a weak constant response in Gx and nothing in Gy.

Two details carry over to learned networks. First, the sign of the response matters: a bright-to-dark step and a dark-to-bright step give opposite signs, which is why networks follow convolutions with a non-linearity rather than taking the magnitude. Second, the ramp shows that a derivative kernel responds to gradients, not to objects; "edges" are a property of the kernel's shape, not of the scene.

Receptive-field arithmetic

The receptive field of a unit is the set of input pixels that can change its value. For a stack of layers with kernel sizes kl, strides sl and dilations dl, the receptive-field side length rL after L layers obeys

rL=rL−1+(kL−1)dL∏i=1L−1si,r0=1.
Where the recurrence comes from

Think of the output of layer L−1 as a grid whose cells are spaced jL−1=∏i<Lsi input pixels apart (the "jump"). A kernel of size kL with dilation dL touches kL such cells spread over (kL−1)dL steps of that grid, so it extends the receptive field by (kL−1)dLjL−1 input pixels. Padding shifts where the receptive field is centred but not its size; stride at layer L affects only later layers, through the jump.

With stride 1 and no dilation the recurrence is linear: rL=1+L(k−1). Stacking 3×3 kernels grows the receptive field by two pixels per layer, a 5×5 by four, a 7×7 by six. Striding or dilating changes the picture completely, because every later layer inherits the multiplied jump.

def receptive_field(kernels, strides, dilations=None):
    dilations = dilations or [1] * len(kernels)
    r, jump, out = 1, 1, [1]
    for k, s, d in zip(kernels, strides, dilations):
        r += (k - 1) * d * jump
        jump *= s
        out.append(r)
    return out
Receptive field size versus depth for several kernel configurations
Receptive-field side length after each of eight layers (log scale). Plain 3×3 stacks grow linearly; one stride-2 layer at the start doubles the growth rate of everything after it; exponentially increasing dilation reaches 511 pixels in eight layers with no loss of resolution.

The dilated configuration (dl=2l) is the trick behind WaveNet-style and segmentation architectures: a receptive field of 511 in eight layers while the output stays at full resolution, whereas plain 3×3 layers reach only 17.

Why two 3×3 layers instead of one 5×5

Two stacked 3×3 layers see exactly the same 5×5 window as one 5×5 layer. The difference is the cost and the non-linearity count. With C input and C output channels and no biases:

Receptive field Option Weights C=64 C=256 Non-linearities
5 × 5 one 5 × 5 25C2 102,400 1,638,400 1
5 × 5 two 3 × 3 18C2 73,728 1,179,648 2
7 × 7 one 7 × 7 49C2 200,704 3,211,264 1
7 × 7 three 3 × 3 27C2 110,592 1,769,472 3
Two stacked 3×3 layers see the same 5×5 window as one 5×5 layer (1-D cross-section) two 3×3 layers, stride 1 input: 5 pixels contribute (receptive field 5) layer 1 output (RF 3 each) layer 2 output (RF 5) weights: 2 × (3·3·C·C) = 18 C² + two non-linearities one 5×5 layer, stride 1 input: the same 5 pixels (receptive field 5) output (RF 5) weights: 5·5·C·C = 25 C² + one non-linearity general rule: rL = rL−1 + (kL − 1) · dL · Πi<L si (kernel k, dilation d, strides s of earlier layers)
A one-dimensional cross-section of the two options. Both outputs depend on the same five input pixels; the stacked version uses 28 % fewer weights for the same receptive field and inserts an extra non-linearity.

The stacked option has fewer parameters and more non-linearities, so it is both cheaper and more expressive; the price is that the composed linear map is constrained (a product of two 3×3 filters is a 5×5 filter of a particular low-rank form, which is why the non-linearity in between matters). The same argument, carried to its conclusion, is the design rule of VGG: use only 3×3 kernels and get large receptive fields from depth.

The effective receptive field is smaller than the arithmetic says

The recurrence counts every pixel that can influence the output. How much each pixel actually influences it is a different question. Repeatedly applying a 3×3 averaging kernel to an impulse shows the answer: the influence profile is a repeated convolution of box filters, which converges to a Gaussian, so pixels near the edge of the theoretical receptive field contribute almost nothing.

Normalized sensitivity profile across the receptive field for stacked box filters
Normalized sensitivity of the centre output to each input offset for stacks of 1, 2, 4 and 8 box filters. The nominal receptive field of the 8-layer stack is 17 pixels wide, but the outer pixels have a few percent of the centre's weight.

Luo et al. measured this on trained networks and found the same Gaussian-like profile, with an effective radius growing like L rather than L. Practically, this means that a network whose nominal receptive field just covers the object is probably not seeing all of it, which is one of the arguments for strided or dilated layers, and later for attention.

Summary

  • conv2d is a sum of k2 shifted, strided, weighted slices; that view also explains the output-size formula.
  • Receptive fields grow additively with kernel size and multiplicatively with stride and dilation.
  • Two 3×3 layers replace one 5×5 with 18C2 instead of 25C2 weights and one more non-linearity; three replace a 7×7 with 27C2 instead of 49C2.
  • The effective receptive field is Gaussian-shaped and considerably smaller than the nominal one.

Further reading