Fall 2026
  • Discord
  • Gradescope
  • Syllabus
  • Spring 2026

Demo: Where Does a Convolutional Network Look?

Train a small convolutional network on handwritten digits, inspect the \(5\times5\) kernels it learned, and use class activation maps to watch where in the image it looks before it answers.

import numpy as np
import matplotlib.pyplot as plt
import scienceplots
import torch
import torch.nn as nn
from matplotlib.colors import LinearSegmentedColormap
from sklearn.datasets import fetch_openml

plt.style.use(["science", "no-latex"])
TEAL, CARDINAL, GRAY = "#009090", "#9c1b33", "#c9c9c9"
KERNEL_CMAP = LinearSegmentedColormap.from_list("course_div", [CARDINAL, "white", TEAL])
FEATURE_CMAP = LinearSegmentedColormap.from_list("course_seq", ["white", TEAL])
CAM_CMAP = LinearSegmentedColormap.from_list("course_cam", [(0, 0.5, 0.5, 0.0), (0, 0.5, 0.5, 0.85)])

torch.manual_seed(19)
rng = np.random.default_rng(19)
X, y = fetch_openml("mnist_784", version=1, return_X_y=True, as_frame=False, parser="auto")
y = y.astype(int)
idx = rng.choice(len(X), size=22000, replace=False)
X, y = X[idx] / 255.0, y[idx]
n_train = 20000
Xtr = torch.tensor(X[:n_train].reshape(-1, 1, 28, 28), dtype=torch.float32)
ytr = torch.tensor(y[:n_train], dtype=torch.long)
Xte = torch.tensor(X[n_train:].reshape(-1, 1, 28, 28), dtype=torch.float32)
yte = torch.tensor(y[n_train:], dtype=torch.long)

class CNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 8, kernel_size=5, padding=2)    # 8 kernels of 5x5
        self.conv2 = nn.Conv2d(8, 16, kernel_size=5, padding=2)   # 16 kernels of 5x5x8
        self.head = nn.Linear(16, 10)
    def feature_maps(self, x):
        h = torch.max_pool2d(torch.relu(self.conv1(x)), 2)   # 8 maps, 14x14
        h = torch.max_pool2d(torch.relu(self.conv2(h)), 2)   # 16 maps, 7x7
        return h
    def forward(self, x):
        return self.head(self.feature_maps(x).mean(dim=(2, 3)))  # global average, then linear

model = CNN()
for name, param in model.named_parameters():
    print(f"{name:12s} {str(tuple(param.shape)):16s} {param.numel():>6,} parameters")
print(f"{'total':29s} {sum(p.numel() for p in model.parameters()):>6,} parameters")
conv1.weight (8, 1, 5, 5)        200 parameters
conv1.bias   (8,)                  8 parameters
conv2.weight (16, 8, 5, 5)     3,200 parameters
conv2.bias   (16,)                16 parameters
head.weight  (10, 16)            160 parameters
head.bias    (10,)                10 parameters
total                          3,594 parameters

The whole network is \(3{,}594\) parameters. The size of its image inputs appears nowhere in the count, only kernel sizes and channel counts.

Train it on \(20{,}000\) digits.

opt = torch.optim.Adam(model.parameters(), lr=5e-3)
lossfn = nn.CrossEntropyLoss()
for epoch in range(20):
    perm = torch.randperm(len(Xtr))
    for i in range(0, len(Xtr), 128):
        batch = perm[i:i + 128]
        opt.zero_grad()
        lossfn(model(Xtr[batch]), ytr[batch]).backward()
        opt.step()

with torch.no_grad():
    preds = torch.cat([model(Xte[i:i + 500]).argmax(dim=1) for i in range(0, len(Xte), 500)])
    acc_cnn = (preds == yte).float().mean().item()
print(f"CNN test accuracy: {acc_cnn:.1%}")
CNN test accuracy: 95.9%

How good is that for the parameter budget? Train the fully connected network from the Finetuning lecture (\(784\)–\(128\)–\(10\)) on the same images, with the same optimizer, the same learning rate, and the same number of epochs.

torch.manual_seed(20)
mlp = nn.Sequential(nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 10))
opt = torch.optim.Adam(mlp.parameters(), lr=5e-3)
Xtr_flat, Xte_flat = Xtr.reshape(-1, 784), Xte.reshape(-1, 784)
for epoch in range(20):
    perm = torch.randperm(len(Xtr_flat))
    for i in range(0, len(Xtr_flat), 128):
        batch = perm[i:i + 128]
        opt.zero_grad()
        lossfn(mlp(Xtr_flat[batch]), ytr[batch]).backward()
        opt.step()

with torch.no_grad():
    acc_mlp = (mlp(Xte_flat).argmax(dim=1) == yte).float().mean().item()
n_cnn = sum(p.numel() for p in model.parameters())
n_mlp = sum(p.numel() for p in mlp.parameters())
print(f"CNN:             {acc_cnn:.1%} accuracy from {n_cnn:>7,} parameters")
print(f"Fully connected: {acc_mlp:.1%} accuracy from {n_mlp:>7,} parameters ({n_mlp / n_cnn:.0f}x more)")
CNN:             95.9% accuracy from   3,594 parameters
Fully connected: 96.8% accuracy from 101,770 parameters (28x more)

Essentially the same accuracy, from \(28\times\) fewer parameters. The fully connected model spends \(100{,}480\) parameters on its first layer alone, more than the whole convolutional network by a factor of nearly thirty.

The learned kernels

In lecture we slid the hand-picked kernel \((-1, 0, 1)\) across a signal and found an edge detector. Here are the eight \(5\times5\) kernels the first layer chose to learn (teal positive, cardinal negative).

kernels = model.conv1.weight.detach().numpy()[:, 0]
vmax = np.abs(kernels).max()
fig, axes = plt.subplots(2, 4, figsize=(7, 3.6))
for k, ax in enumerate(axes.flat):
    ax.imshow(kernels[k], cmap=KERNEL_CMAP, vmin=-vmax, vmax=vmax)
    ax.set_xlabel(f"Kernel {k + 1}")
    ax.set_xticks([])
    ax.set_yticks([])
plt.show()

Almost every kernel is an oriented light-to-dark transition: horizontal, vertical, or diagonal edge detectors, the two-dimensional version of the \((-1, 0, 1)\) kernel. Nobody asked for edge detectors; gradient descent rediscovered them.

One kernel at work

A kernel is a pattern detector, and its feature map is an image of where the pattern occurs. Slide each first-layer kernel over one test digit.

digit = 0  # index into the test set
with torch.no_grad():
    maps = torch.relu(model.conv1(Xte[digit:digit + 1]))[0].numpy()  # 8 maps at full 28x28

fig, axes = plt.subplots(2, 4, figsize=(7, 3.6))
for k, ax in enumerate(axes.flat):
    ax.imshow(maps[k], cmap=FEATURE_CMAP)
    ax.set_xlabel(f"Feature map {k + 1}")
    ax.set_xticks([])
    ax.set_yticks([])
plt.show()
print(f"The digit is a {yte[digit].item()}.")

The digit is a 6.

Each map lights up on a different aspect of the same strokes (one traces the left-facing curves, another the right), and each responds to its pattern wherever it sits, because the same \(25\) weights visit every pixel.

Class activation maps

The network ends with a global average and a linear layer, so the score for class \(c\) is the spatial average of a single image, the class activation map

\[\mathrm{CAM}_c = \sum_{k=1}^{16} w_{c,k} \mathbf{A}_k,\]

where \(\mathbf{A}_k\) is the \(k\)-th feature map of the last convolutional layer and \(w_{c,k}\) its weight in the head. Overlay each digit’s map for its predicted class.

W = model.head.weight.detach().numpy()  # 10 x 16
shown = [i for i in range(len(Xte)) if preds[i] == yte[i]][:4]  # first four correct predictions

fig, axes = plt.subplots(1, 4, figsize=(8, 2.4))
for ax, i in zip(axes, shown):
    with torch.no_grad():
        maps = model.feature_maps(Xte[i:i + 1])[0].numpy()  # 16 maps, 7x7
    cam = np.maximum((W[preds[i]][:, None, None] * maps).sum(axis=0), 0)
    cam = np.kron(cam / max(cam.max(), 1e-9), np.ones((4, 4)))  # upsample 7x7 -> 28x28
    ax.imshow(Xte[i, 0].numpy(), cmap="gray_r")
    ax.imshow(cam, cmap=CAM_CMAP, vmin=0, vmax=1)
    ax.set_xlabel(f"Predicted {preds[i].item()}")
    ax.set_xticks([])
    ax.set_yticks([])
plt.show()

The heat sits on each digit’s most distinctive strokes, not uniformly over the ink.

What if we ask for a class that isn’t there?

The map \(\mathrm{CAM}_c\) is defined for every class \(c\), not just the winner. Take an \(8\) from the test set and ask: which strokes would have voted for a \(3\)?

target_class = 3   # change me!

i = int((yte == 8).nonzero()[0])
with torch.no_grad():
    maps = model.feature_maps(Xte[i:i + 1])[0].numpy()
    pred = model(Xte[i:i + 1]).argmax(dim=1).item()

fig, axes = plt.subplots(1, 2, figsize=(5, 2.4))
for ax, c in zip(axes, [pred, target_class]):
    cam = np.maximum((W[c][:, None, None] * maps).sum(axis=0), 0)
    cam = np.kron(cam / max(cam.max(), 1e-9), np.ones((4, 4)))
    ax.imshow(Xte[i, 0].numpy(), cmap="gray_r")
    ax.imshow(cam, cmap=CAM_CMAP, vmin=0, vmax=1)
    ax.set_xlabel(f"CAM for class {c}")
    ax.set_xticks([])
    ax.set_yticks([])
plt.show()

Punchline: a few thousand weights, reused at every pixel, match a hundred-thousand-parameter dense network, and because every weight has a place, we can watch the network decide where to look.