7  Filters and Convolution (Fixed Kernels)

Part I ended with a diagnosis and a prescription. The diagnosis: our MLP’s templates span the whole frame, so they are blind to adjacency and brittle to position (Chapter 6). The prescription, in one sentence: make the template local, and slide it.

This chapter builds that machine with our bare hands — no learning anywhere. It is a piece of classical engineering, decades older than deep learning, and computer-vision experts spent careers designing its templates manually. Understanding the machine in its fixed, hand-crafted form is the whole point of the chapter, because Part II’s revolution (Chapter 8) will consist of exactly one change to it.

Recall the primitive from Chapter 1: a dot product scores an input against a template. When their norms are controlled, its angle component measures similarity. Everything below is that one primitive, applied locally, everywhere.

7.1 Two principles, one strategy

The failures of Chapter 6 tell us what knowledge to build in, the two principles from the mechanism:

  1. Spatial locality. Nearby pixels are more related than distant ones. Edges, textures, and patterns emerge from local neighborhoods, not global pixel arrangements.
  2. Translation equivariance. The same feature detector should work regardless of position — a cat’s ear is a cat’s ear whether it appears top-left or bottom-right.

The strategy they suggest: instead of one massive network staring at the entire image, use a small detector that analyzes one patch at a time, and slide it across the image to look for its feature everywhere. To understand what such detectors do, we first look at how vision experts used to design them by hand.

7.2 Start in 1D: the moving average

The core operation appears in its friendliest form when smoothing a noisy signal. A moving average replaces each point by the mean of its neighborhood: locality, with uniform weights:

  1. Prepare the inputs and fixed settings for the example.
  2. Apply a one-dimensional moving-average filter.
import torch
import torch.nn.functional as F
import matplotlib.pyplot as plt

# [1]
torch.manual_seed(6050)
t = torch.linspace(0, 4 * torch.pi, 300)
noisy = torch.sin(t) + 0.35 * torch.randn(300)
kernel = torch.full((9,), 1 / 9)                       # uniform local weights
# [2]
smooth = F.conv1d(noisy[None, None], kernel[None, None]).squeeze()
A jagged noisy signal fluctuates around a smooth periodic trend. The nine-point moving-average curve removes the high-frequency variation while following the central oscillation.
Figure 7.1: A window of uniform weights, slid along a noisy signal, averaging as it goes: the simplest sliding-window operation, and already recognizably a denoiser.

7.3 To 2D: the recipe

The image version is the same idea with a small 2-D window, and it is worth stating as the five-step recipe, set beside the three lines that carry it:

  1. Define a small window of weights, the kernel (for a blur: all positive, summing to 1).
  2. Place the kernel over a patch of the image.
  3. Multiply elementwise: kernel weights against the pixels underneath.
  4. Sum the products into one output pixel. (Steps 3–4 are one dot product: the patch, scored against a small template.)
  5. Slide the window to the next location and repeat, producing a new image.
def manual_conv2d(x: torch.Tensor, k: torch.Tensor) -> torch.Tensor:
    """Cross-correlate an image and kernel over a view of sliding patches."""
    kh, kw = k.shape                               # [1]
    patches = x.unfold(0, kh, 1).unfold(1, kw, 1)  # [2][5]
    return (patches * k).sum(dim=(-1, -2))         # [3][4]

def conv2d(x: torch.Tensor, k: torch.Tensor) -> torch.Tensor:
    return F.conv2d(x[None, None], k[None, None]).squeeze()

torch.manual_seed(6050)
x_test = torch.rand(10, 12)
k_test = torch.randn(3, 3)
gap = (manual_conv2d(x_test, k_test) - conv2d(x_test, k_test)).abs().max()
print(f"recipe vs. torch.conv2d: max |diff| = {gap:.1e}")
recipe vs. torch.conv2d: max |diff| = 4.8e-07

Read the markers and one thing stands out: steps 2 and 5 share a line. The plan moves one window at a time; unfold places every window at once, and the next line scores them all in parallel. That gap between a sequential plan and a vectorized kernel is where most tensor bugs live, which is why the last line checks this implementation against the framework’s own — the build-then-verify habit of this book.

Also worth noticing before we move on: the output is smaller than the input. A \(k \times k\) kernel on an \(n \times n\) image produces \((n - k + 1) \times (n - k + 1)\) outputs, because the window must stay inside the frame. What to do about that (and about sliding in bigger steps) is Chapter 8’s business.

7.4 The filter zoo

Here is the remarkable part: the recipe never changes — only the numbers in the kernel do. Different numbers produce completely different specialists. Three classics, applied to a synthetic test scene and to a boot from our Fashion-MNIST subset:

  • Blur: uniform positive weights summing to 1, the 2-D moving average.
  • Sharpen: amplify each pixel’s difference from its neighbors.
  • Edge detection (Sobel): positive and negative weights summing to zero — over any flat region the products cancel and the output is silent; over an edge, a sharp change in values, the sum swings large. A detector that answers one question: does intensity change here, in my direction?
  1. Define the reusable make_shapes helper.
  2. Prepare the inputs and fixed settings for the example.
  3. Implement the zoo.
# [1]
def make_shapes(n: int = 64) -> torch.Tensor:
    img = torch.zeros(n, n)
    img[10:26, 8:30] = 0.9                                   # rectangle
    yy, xx = torch.meshgrid(torch.arange(n), torch.arange(n), indexing="ij")
    img[((yy - 44) ** 2 + (xx - 20) ** 2) < 100] = 0.6       # disk
    stripes = ((xx + yy) % 12 < 5).float() * 0.8
    img[8:56, 40:60] = stripes[8:56, 40:60]                  # diagonal stripes
    return img

# [2]
development = torch.load("../../data/fashion-train.pt")
boot = development["X"][(development["y"] == 9).nonzero()[0, 0]].float() / 255.0

kernels = {
    "original": torch.tensor([[0., 0., 0.], [0., 1., 0.], [0., 0., 0.]]),
    "blur": torch.full((3, 3), 1 / 9),
    "sharpen": torch.tensor([[0., -1., 0.], [-1., 5., -1.], [0., -1., 0.]]),
    "Sobel (vert.)": torch.tensor([[-1., 0., 1.], [-2., 0., 2.], [-1., 0., 1.]]),
    "Sobel (horiz.)": torch.tensor([[-1., -2., -1.], [0., 0., 0.], [1., 2., 1.]]),
}

fig, axes = plt.subplots(2, 5, figsize=(7.8, 3.4))
# [3]
for row, image in enumerate([make_shapes(), boot]):
    for col, (name, k) in enumerate(kernels.items()):
        out = conv2d(image, k)
        axes[row, col].imshow(out, cmap="gray")
        axes[row, col].set_xticks([]); axes[row, col].set_yticks([])
        if row == 0:
            axes[row, col].set_title(name, fontsize=9)
plt.tight_layout(); plt.show()
Two image rows, a synthetic shapes scene and a boot, are processed across five columns. Blur softens both inputs, sharpen strengthens local contrast, and the two Sobel outputs illuminate boundaries of different orientations.
Figure 7.2: One recipe, five kernels. Each column applies a different 3×3 kernel to the same inputs: identity, blur (local average), sharpen, and two Sobel edge detectors. The vertical-edge kernel fires on vertical boundaries; the horizontal one fires on horizontal boundaries.

Study the two Sobel columns for a moment. The vertical-edge detector lights up on the rectangle’s sides and stays silent on its top and bottom; the horizontal detector does the reverse; the diagonal stripes excite both. Nine numbers, and we have built a primitive feature detector — precisely the “small network analyzing one patch” of our strategy, except nobody has learned anything yet.

7.5 Naming the operation

This sliding sum-of-products has a proper name: cross-correlation. With kernel \(V\) of half-width \(\Delta\) and image \(X\):

\[ [H]_{i,j} \;=\; \sum_{a=-\Delta}^{\Delta}\;\sum_{b=-\Delta}^{\Delta} [V]_{a,b}\,[X]_{i+a,\,j+b} . \tag{7.1}\]

Note“Convolution”, with a small asterisk

The deep learning community universally calls this operation a convolution, and so will we. A mathematician’s convolution flips the kernel before sliding; since our kernels will soon be learned parameters, a flipped kernel is just another kernel, and the distinction is inconsequential in practice (Exercise 4 makes you confirm this).

7.6 The property we paid for: equivariance

Now collect the reward that Chapter 6’s MLP could never have. Away from boundaries, slide-and-score treats every position identically \(\rightarrow\) if the input shifts, the output shifts with it, unchanged in content. That is translation equivariance, and it need not be taken on faith:

  1. Prepare the inputs and fixed settings for the example.
  2. Compare filtering after a shift with shifting after filtering.
# [1]
img = make_shapes()
sobel = kernels["Sobel (vert.)"]
s = 5

shifted = torch.zeros_like(img)
shifted[:, s:] = img[:, :-s]                  # input shifted right by 5

A = conv2d(shifted, sobel)                    # filter the shifted image
B = conv2d(img, sobel)                        # filter, THEN shift the result
B_shifted = torch.zeros_like(B)
B_shifted[:, s:] = B[:, :-s]

interior = (A - B_shifted)[:, s + 2:]         # compare away from the blank border
# [2]
print(f"filter(shift(x)) vs shift(filter(x)): max |diff| = {interior.abs().max():.1f}")
filter(shift(x)) vs shift(filter(x)): max |diff| = 0.0

Exactly zero on the compared interior. The detector’s report moves with the garment. Contrast Chapter 6: the MLP’s global templates degraded under a 2-pixel shift because position was baked into every weight. Here, position cannot matter to what is detected on the interior, only to where the detection lands. At the frame boundary, cropping, padding, or fill rules can break exact equality; that is why the code excludes the blank-border region.

One precision worth keeping sharp, because the two words will both matter: equivariant means the output moves along with the input (what convolution gives us); invariant means the output does not change at all (what a classifier ultimately wants — “boot”, regardless of position). Equivariance is the raw material; in Chapter 8, pooling will spend some position information to buy local shift tolerance. Exact invariance is a stronger property and must be tested, not presumed.

7.7 The matrix view: a structured, weight-shared linear map

One more pair of glasses. Every output pixel is a dot product \(\rightarrow\) the whole operation is linear \(\rightarrow\) cross-correlation could be written as one enormous matrix multiplying the flattened image. But it is a matrix with a rigid structure: almost everywhere zero (locality: each row touches only one patch), and the same nine numbers repeating along its diagonals (sharing: every row is the same template, relocated).

That view makes the economics explicit. A dense layer from our 28×28 images to an equal-sized output would own \(784 \times 784 \approx 615{,}000\) weights. The Sobel detector owns nine — reused at every position. This is Chapter 6’s constraints-are-knowledge callout made concrete: locality zeroes out most of the matrix, equivariance ties the survivors together, and what remains is a linear map with almost nothing left to specify.

  1. Construct the sparse weight-shared matrix.
from matplotlib.colors import ListedColormap

# [1]
weight_ids = torch.zeros(4, 6)
for row in range(4):
    weight_ids[row, row:row + 3] = torch.tensor([1, 2, 3])
A four-by-six matrix grid contains mostly zeros. Each row has the same three adjacent symbols v-one, v-two, and v-three, shifted one column right from the row above to form three repeating diagonals.
Figure 7.3: The 1-D version of convolution as a structured matrix (shown in 1-D only so the pattern is visible). Each output row touches one local three-value window, so most entries are zero. The same three kernel weights repeat along diagonals: locality creates sparsity; sharing ties the nonzero entries together. A 2-D image produces the analogous block-structured matrix.

7.8 The bottleneck: someone has to design the kernel

So why did this classical machinery not solve vision decades ago? Because the kernels are only as good as their designers, and hand-crafted kernels have three limitations the operator exposes:

  1. Limited expressiveness. They detect simple, predefined patterns — gradients, blobs, corners. Nobody can write down nine numbers for “cat ear” or “car wheel.”
  2. Manual feature engineering. Choosing which kernels to use, and how to combine them, takes deep domain expertise and endless experimentation — for every new task.
  3. No adaptation. A fixed kernel cannot adjust to a dataset. What works for one task fails for another.

An entire era of computer vision lived inside these constraints, stacking hand-designed detectors into elaborate pipelines. The machine was right; the templates were the bottleneck.

You can hear the question coming. We have a device that is local, weight-shared, and equivariant by construction — with a handful of numbers sitting inside it, currently chosen by hand. Nine numbers. We have spent four chapters building tools that tune numbers against a loss.

What if the template were learnable? That question is Chapter 8, and its answer has a name you already know.

NoteCheck yourself

Close the book for one minute and reconstruct the sliding operation.

  • Which two design commitments create locality and translation equivariance?
  • Why does weight sharing turn a dense linear map into a small kernel?
  • What remains hand designed at the end of this chapter?

7.9 Okay, so — the machine before the learning

  1. One operation, many specialists: slide a small template, take a dot product at every position (Equation 7.1). Blur, sharpen, and edge detection differ only in the kernel’s numbers.
  2. Locality and sharing are built in: as a matrix, convolution is almost-all-zero with nine numbers repeating — 615,000 weights collapsed to 9.
  3. Equivariance is exact on the interior, not approximate: filter-then-shift equals shift-then-filter wherever both operations see the same pixels. Boundary handling must be specified separately. The next chapter asks how much local shift tolerance pooling can purchase from that equivariance.
  4. Hand-crafting is the bottleneck — expressiveness, engineering cost, no adaptation — and it is exactly the kind of bottleneck this book knows how to break.

Sources and further reading

Exercises

  1. (Pencil.) Compute by hand the full output of cross-correlating with the vertical Sobel kernel the \(4\times4\) image whose rows are all \((0,0,1,1)\). Before computing: where should the detector fire?
  2. (Pencil.) (a) Design a \(3\times3\) kernel that detects diagonal edges (bottom-left to top-right). State your design rule: what must the weights sum to, and why? (Code.) (b) Run it on make_shapes() and check that it fires on the stripes.
  3. (Pencil.) Prove translation equivariance from Equation 7.1: substitute the shifted image \(X'_{i,j} = X_{i-s,j}\) and show \(H'_{i,j} = H_{i-s,j}\). Why does the same argument fail for the MLP of Chapter 6? State where the proof applies at a finite image boundary.
  4. (Code.) True convolution flips the kernel: run conv2d with k and with k.flip(0, 1) for the blur and for Sobel. Which outputs differ, which do not, and what property of the kernel decides it?
  5. (Code.) The box blur is separable: show that convolving with the \(3\times3\) uniform kernel equals convolving with a \(3\times1\) column of \(\tfrac13\)s and then a \(1\times3\) row of \(\tfrac13\)s. Count multiplications per output pixel for each route — this trick mattered enormously in the hand-crafted era.