Lecture 16: Convolutional Neural Networks – CS 189, Fall 2026

A fully connected network treats every pixel position as a separate input. Today we build a network that scans the image with small shared kernels. We will:

  • Train an MLP on MNIST and watch its accuracy collapse when the digits move a few pixels.
  • Compute a convolution by hand on the slide's 8×8 "9", then run line and edge kernels on a real digit.
  • Check the output-shape formula for padding, stride, and multiple filters.
  • Pool a feature map and get the slide's 3×3 result.
  • Train a small CNN, count its parameters, and rerun the shift test.
  • Look at what the kernels learned: first-layer weights, the patches that excite layer 2, and inputs optimized to excite it.
  • Ask a pretrained AlexNet where it looks in a photo: occlusion, saliency, and guided backpropagation.

Imports¶

In [119]:
import copy
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
import torchvision as tv
from torchvision import transforms
from torchinfo import summary
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay

%config InlineBackend.figure_format = "retina"

C_BLUE, C_GOLD, C_RED, C_GREEN = "#003262", "#FDB515", "#D55E00", "#00553A"
plt.rcParams.update({"figure.figsize": (8, 5), "axes.grid": False,
                     "font.size": 13, "axes.titlesize": 15, "axes.labelsize": 14})

device = ("cuda" if torch.cuda.is_available()
          else "mps" if torch.backends.mps.is_available() else "cpu")
print("device:", device)
device: mps

Helper Code¶

Run these cells before the rest. train and accuracy are the loops from Lecture 14. shift moves a batch of images dx pixels right and dy pixels down, filling the gap with black.

In [120]:
def shift(x, dx, dy=0):
    """Move images dx pixels right and dy pixels down. Pixels that leave the frame are lost."""
    pad = 10
    x = F.pad(x, (pad, pad, pad, pad))
    x = torch.roll(x, shifts=(dy, dx), dims=(-2, -1))
    return x[..., pad:-pad, pad:-pad]


def train(model, loader, epochs=2, lr=1e-3):
    model.to(device)
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    for epoch in range(epochs):
        model.train()
        total = 0.0
        for xb, yb in loader:
            xb, yb = xb.to(device), yb.to(device)
            opt.zero_grad()
            loss = F.cross_entropy(model(xb), yb)
            loss.backward()
            opt.step()
            total += loss.item() * len(xb)
        print(f"epoch {epoch + 1}: training loss {total / len(loader.dataset):.4f}")
    return model


@torch.no_grad()
def accuracy(model, loader, dx=0):
    model.eval()
    correct = 0
    for xb, yb in loader:
        pred = model(shift(xb, dx).to(device)).argmax(1).cpu()
        correct += (pred == yb).sum().item()
    return correct / len(loader.dataset)


def show_row(images, titles=None, cmap="gray", size=2.2, **kw):
    """Plot a list of 2D arrays side by side."""
    fig, axes = plt.subplots(1, len(images), figsize=(size * len(images), size + 0.4))
    for i, (ax, img) in enumerate(zip(np.atleast_1d(axes), images)):
        ax.imshow(np.asarray(img), cmap=cmap, **kw)
        ax.axis("off")
        if titles is not None:
            ax.set_title(titles[i], fontsize=12)
    plt.tight_layout()
    plt.show()
In [121]:
transform = transforms.ToTensor()            # pixels in [0, 1], shape (1, 28, 28)
train_ds = tv.datasets.MNIST("./data", train=True, download=True, transform=transform)
test_ds = tv.datasets.MNIST("./data", train=False, download=True, transform=transform)
train_loader = DataLoader(train_ds, batch_size=128, shuffle=True)
test_loader = DataLoader(test_ds, batch_size=1000)
print(f"train {len(train_ds):,} images, test {len(test_ds):,} images")
train 60,000 images, test 10,000 images

Step 1: The Failure: An MLP Is Not Shift Invariant (Demo 1)¶

Train a fully connected network (784 → 256 → 10) on MNIST as it comes: every digit is centered in its 28×28 frame.

In [122]:
torch.manual_seed(189)
mlp = nn.Sequential(nn.Flatten(), nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 10))
train(mlp, train_loader, epochs=2)
print(f"test accuracy, centered digits: {accuracy(mlp, test_loader):.3f}")
epoch 1: training loss 0.3582
epoch 2: training loss 0.1587
test accuracy, centered digits: 0.960

The same test digits, moved 4 pixels to the right. The digit itself is unchanged and stays inside the frame.

In [123]:
x, label = test_ds[0]
show_row([x[0], shift(x, 4)[0]], ["original", "moved 4 pixels right"])
print(f"test accuracy, digits moved 4 pixels: {accuracy(mlp, test_loader, dx=4):.3f}")
No description has been provided for this image
test accuracy, digits moved 4 pixels: 0.297
In [124]:
shifts = range(0, 9)
acc_mlp = [accuracy(mlp, test_loader, dx=d) for d in shifts]
plt.plot(shifts, acc_mlp, "o-", color=C_BLUE, lw=2.5, label="MLP")
plt.axhline(0.1, color="gray", ls="--", lw=1, label="chance")
plt.xlabel("pixels moved right"); plt.ylabel("test accuracy"); plt.ylim(0, 1.02)
plt.legend(); plt.title("Fully connected network on moved digits")
plt.show()
No description has been provided for this image

Each weight in the first layer belongs to one pixel position. A stroke that moves 4 pixels lands on weights that never saw it during training. The network learned "a 1 has ink in columns 13–15", not "a 1 is a vertical stroke".


Step 2: One Kernel, Scanned Over the Image¶

The 8×8 "9" from the slides, and the horizontal line detector. F.conv2d slides the kernel over every 3×3 window (stride 1, no padding) and takes the elementwise product and sum.

In [125]:
nine = torch.tensor([[0, 0, 0, 0, 0, 0, 0, 0],
                     [0, 0, 0, 1, 1, 1, 0, 0],
                     [0, 0, 1, 1, 1, 1, 0, 0],
                     [0, 0, 1, 1, 1, 1, 0, 0],
                     [0, 0, 0, 1, 1, 1, 0, 0],
                     [0, 0, 0, 1, 1, 0, 0, 0],
                     [0, 0, 1, 1, 1, 0, 0, 0],
                     [0, 0, 1, 1, 0, 0, 0, 0]], dtype=torch.float32)
horizontal = torch.tensor([[-1, -1, -1], [2, 2, 2], [-1, -1, -1]], dtype=torch.float32)
vertical = horizontal.T

fmap_h = F.conv2d(nine[None, None], horizontal[None, None])[0, 0]   # (batch, channel, H, W)
print("horizontal line detector, 6x6 feature map:\n", fmap_h.int().numpy())
horizontal line detector, 6x6 feature map:
 [[-1  0  1  3  2  1]
 [ 1  1  1  0  0  0]
 [ 1  1  1  0  0  0]
 [-1 -1 -1  1  1  1]
 [-1 -1 -1 -1 -1 -1]
 [ 1  1  2  1  1  0]]

The largest value, 3, sits where the top bar of the 9 is. Same scan with the vertical line detector:

In [126]:
fmap_v = F.conv2d(nine[None, None], vertical[None, None])[0, 0]
print("vertical line detector, 6x6 feature map:\n", fmap_v.int().numpy())
vertical line detector, 6x6 feature map:
 [[-1  0  1  0  2 -2]
 [-2  1  1  0  3 -3]
 [-2  1  1  0  3 -3]
 [-1 -1  2  1  1 -2]
 [-1 -1  2  2 -1 -1]
 [-2  1  2  1 -2  0]]

Step 3: Kernels on a Real Digit (Demo 2)¶

The same line detectors on a 28×28 MNIST "9", with padding 1 so the output stays 28×28. Bright pixels in the output mark where the pattern in the kernel appears.

In [127]:
nine_real = next(x for x, y in train_ds if y == 9)[None]          # (1, 1, 28, 28)
diagonal = torch.tensor([[2, -1, -1], [-1, 2, -1], [-1, -1, 2]], dtype=torch.float32)
line_kernels = {"horizontal": horizontal, "vertical": vertical, "diagonal": diagonal}

outs = [F.conv2d(nine_real, k[None, None], padding=1)[0, 0] for k in line_kernels.values()]
show_row([nine_real[0, 0]] + [k for k in line_kernels.values()] + outs,
         ["input"] + [f"{n} kernel" for n in line_kernels] + [f"{n} output" for n in line_kernels])
No description has been provided for this image

Classic image-processing kernels are convolutions too. None of these were learned; each was designed by hand.

In [128]:
classic = {"identity": [[0, 0, 0], [0, 1, 0], [0, 0, 0]],
           "Sobel horizontal edge": [[-1, -2, -1], [0, 0, 0], [1, 2, 1]],
           "Sobel vertical edge": [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]],
           "sharpen": [[0, -1, 0], [-1, 5, -1], [0, -1, 0]],
           "box blur": [[1 / 9] * 3] * 3}
outs = [F.conv2d(nine_real, torch.tensor(k, dtype=torch.float32).view(1, 1, 3, 3), padding=1)[0, 0] for k in classic.values()]
show_row(outs, list(classic), size=2.6)
No description has been provided for this image

Step 4: Output Shapes: Padding, Stride, and Many Filters¶

For a $J \times K$ input, an $M \times M$ kernel, padding $P$ and stride $S$, each spatial side of the output is $\lfloor (J + 2P - M)/S \rfloor + 1$. Check the formula against PyTorch on a blank 28×28 image.

In [129]:
def out_size(J, M, P=0, S=1):
    return (J + 2 * P - M) // S + 1

x = torch.zeros(1, 1, 28, 28)
for M, P, S in [(3, 0, 1), (3, 1, 1), (3, 0, 2), (3, 1, 2), (5, 2, 1)]:
    y = F.conv2d(x, torch.zeros(1, 1, M, M), padding=P, stride=S)
    print(f"M={M} P={P} S={S}: formula {out_size(28, M, P, S)}, PyTorch {tuple(y.shape[-2:])}")
M=3 P=0 S=1: formula 26, PyTorch (26, 26)
M=3 P=1 S=1: formula 28, PyTorch (28, 28)
M=3 P=0 S=2: formula 13, PyTorch (13, 13)
M=3 P=1 S=2: formula 14, PyTorch (14, 14)
M=5 P=2 S=1: formula 28, PyTorch (28, 28)
In [130]:
conv = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=5)       # stride 1, no padding
rgb = torch.randn(1, 3, 32, 32)
print("output shape:", tuple(conv(rgb).shape), "  (batch, filters, height, width)")
print("weight shape:", tuple(conv.weight.shape), "  (filters, input channels, M, M)")
print(f"parameters: {sum(p.numel() for p in conv.parameters())} = 16 * (5*5*3) + 16 biases")
output shape: (1, 16, 28, 28)   (batch, filters, height, width)
weight shape: (16, 3, 5, 5)   (filters, input channels, M, M)
parameters: 1216 = 16 * (5*5*3) + 16 biases

Step 5: Pooling¶

2×2 max pooling keeps the largest value in each 2×2 block. Applied to the two 6×6 feature maps from Step 2, it gives the 3×3 maps on the pooling slides.

In [131]:
pool = nn.MaxPool2d(2)
print("horizontal, pooled:\n", pool(fmap_h[None, None])[0, 0].int().numpy())
print("vertical, pooled:\n", pool(fmap_v[None, None])[0, 0].int().numpy())
horizontal, pooled:
 [[1 3 2]
 [1 1 1]
 [1 2 1]]
vertical, pooled:
 [[1 1 3]
 [1 2 3]
 [1 2 0]]

On the real digit: max pooling keeps the strongest response in each block, average pooling blurs it.

In [132]:
h_out = F.relu(F.conv2d(nine_real, horizontal[None, None], padding=1))
imgs = [h_out, nn.MaxPool2d(2)(h_out), nn.MaxPool2d(4)(h_out), nn.AvgPool2d(4)(h_out)]
show_row([t[0, 0] for t in imgs],
         [f"{name}\n{tuple(t.shape[-2:])}" for name, t in
          zip(["horizontal output", "max pool 2x2", "max pool 4x4", "avg pool 4x4"], imgs)], size=2.8)
No description has been provided for this image

Step 6: A Small CNN (Demo 3)¶

Two convolution blocks (convolution, ReLU, 2×2 max pooling), then a fully connected classifier. The same network as the slides.

In [133]:
class SmallCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),    # 8 x 14 x 14
            nn.Conv2d(8, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),   # 64 x 7 x 7
        )
        self.classifier = nn.Sequential(
            nn.Flatten(), nn.Linear(64 * 7 * 7, 256), nn.ReLU(), nn.Dropout(0.25), nn.Linear(256, 10))

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

summary(SmallCNN(), input_size=(1, 1, 28, 28), col_names=("output_size", "num_params"), depth=2)
Out[133]:
==========================================================================================
Layer (type:depth-idx)                   Output Shape              Param #
==========================================================================================
SmallCNN                                 [1, 10]                   --
├─Sequential: 1-1                        [1, 64, 7, 7]             --
│    └─Conv2d: 2-1                       [1, 8, 28, 28]            80
│    └─ReLU: 2-2                         [1, 8, 28, 28]            --
│    └─MaxPool2d: 2-3                    [1, 8, 14, 14]            --
│    └─Conv2d: 2-4                       [1, 64, 14, 14]           4,672
│    └─ReLU: 2-5                         [1, 64, 14, 14]           --
│    └─MaxPool2d: 2-6                    [1, 64, 7, 7]             --
├─Sequential: 1-2                        [1, 10]                   --
│    └─Flatten: 2-7                      [1, 3136]                 --
│    └─Linear: 2-8                       [1, 256]                  803,072
│    └─ReLU: 2-9                         [1, 256]                  --
│    └─Dropout: 2-10                     [1, 256]                  --
│    └─Linear: 2-11                      [1, 10]                   2,570
==========================================================================================
Total params: 810,394
Trainable params: 810,394
Non-trainable params: 0
Total mult-adds (M): 1.78
==========================================================================================
Input size (MB): 0.00
Forward/backward pass size (MB): 0.15
Params size (MB): 3.24
Estimated Total Size (MB): 3.40
==========================================================================================
In [134]:
cnn = SmallCNN()
for name in ["features.0", "features.3", "classifier.1"]:
    layer = dict(cnn.named_modules())[name]
    print(f"{name:13s} weight {tuple(layer.weight.shape)}: {sum(p.numel() for p in layer.parameters()):,} parameters")
print(f"conv1 by hand: 8 * (1*3*3) + 8  = {8 * 9 + 8}")
print(f"conv2 by hand: 64 * (8*3*3) + 64 = {64 * 8 * 9 + 64}")
features.0    weight (8, 1, 3, 3): 80 parameters
features.3    weight (64, 8, 3, 3): 4,672 parameters
classifier.1  weight (256, 3136): 803,072 parameters
conv1 by hand: 8 * (1*3*3) + 8  = 80
conv2 by hand: 64 * (8*3*3) + 64 = 4672

Almost all of the 810,394 parameters sit in the first fully connected layer. Train for 2 epochs (about 30 seconds on a laptop CPU).

In [135]:
torch.manual_seed(189)
cnn = train(SmallCNN(), train_loader, epochs=2)
print(f"test accuracy, centered digits: {accuracy(cnn, test_loader):.3f}")
epoch 1: training loss 0.2998
epoch 2: training loss 0.0768
test accuracy, centered digits: 0.983
In [136]:
cnn.eval()
with torch.no_grad():
    preds = torch.cat([cnn(xb.to(device)).argmax(1).cpu() for xb, _ in test_loader])
ConfusionMatrixDisplay(confusion_matrix(test_ds.targets, preds)).plot(cmap="Blues", colorbar=False)
plt.title("SmallCNN on the test set")
plt.show()
No description has been provided for this image

Now the shift test from Step 1:

In [137]:
acc_cnn = [accuracy(cnn, test_loader, dx=d) for d in shifts]
plt.plot(shifts, acc_mlp, "o-", color=C_BLUE, lw=2.5, label="MLP")
plt.plot(shifts, acc_cnn, "s-", color=C_GOLD, lw=2.5, label="SmallCNN")
plt.xlabel("pixels moved right"); plt.ylabel("test accuracy"); plt.ylim(0, 1.02)
plt.legend(); plt.title("Moved digits: MLP vs. CNN")
plt.show()
print("shift:   " + "".join(f"{d:6d}" for d in shifts))
print("MLP:     " + "".join(f"{a:6.2f}" for a in acc_mlp))
print("CNN:     " + "".join(f"{a:6.2f}" for a in acc_cnn))
No description has been provided for this image
shift:        0     1     2     3     4     5     6     7     8
MLP:       0.96  0.93  0.73  0.48  0.30  0.19  0.12  0.08  0.07
CNN:       0.98  0.98  0.95  0.86  0.64  0.43  0.25  0.16  0.12

The failure: the classifier head is not shift invariant¶

Better, but still falling. Convolution moves its output with the input (a shifted digit gives a shifted feature map), and each 2×2 pooling absorbs a 1-pixel move. The Linear(64*7*7, 256) layer then gives every position of the 7×7 map its own weights, the same problem the MLP had.

Fix: no fully connected layer. The last convolution outputs one map per class, and a global max pool takes the largest value anywhere in each map.

In [138]:
torch.manual_seed(189)
global_cnn = nn.Sequential(
    nn.Conv2d(1, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(16, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
    nn.Conv2d(64, 10, 1),                      # one 7x7 map per class
    nn.AdaptiveMaxPool2d(1), nn.Flatten())     # largest value anywhere in each map

summary(global_cnn, input_size=(1, 1, 28, 28), col_names=("output_size", "num_params"), depth=2)
Out[138]:
==========================================================================================
Layer (type:depth-idx)                   Output Shape              Param #
==========================================================================================
Sequential                               [1, 10]                   --
├─Conv2d: 1-1                            [1, 16, 28, 28]           160
├─BatchNorm2d: 1-2                       [1, 16, 28, 28]           32
├─ReLU: 1-3                              [1, 16, 28, 28]           --
├─MaxPool2d: 1-4                         [1, 16, 14, 14]           --
├─Conv2d: 1-5                            [1, 32, 14, 14]           4,640
├─BatchNorm2d: 1-6                       [1, 32, 14, 14]           64
├─ReLU: 1-7                              [1, 32, 14, 14]           --
├─MaxPool2d: 1-8                         [1, 32, 7, 7]             --
├─Conv2d: 1-9                            [1, 64, 7, 7]             18,496
├─BatchNorm2d: 1-10                      [1, 64, 7, 7]             128
├─ReLU: 1-11                             [1, 64, 7, 7]             --
├─Conv2d: 1-12                           [1, 10, 7, 7]             650
├─AdaptiveMaxPool2d: 1-13                [1, 10, 1, 1]             --
├─Flatten: 1-14                          [1, 10]                   --
==========================================================================================
Total params: 24,170
Trainable params: 24,170
Non-trainable params: 0
Total mult-adds (M): 1.97
==========================================================================================
Input size (MB): 0.00
Forward/backward pass size (MB): 0.36
Params size (MB): 0.10
Estimated Total Size (MB): 0.46
==========================================================================================
In [139]:
train(global_cnn, train_loader, epochs=2, lr=3e-3)
acc_global = [accuracy(global_cnn, test_loader, dx=d) for d in shifts]
epoch 1: training loss 0.3438
epoch 2: training loss 0.1271
In [140]:
for acc, style, color, name in [(acc_mlp, "o-", C_BLUE, "MLP"), (acc_cnn, "s-", C_GOLD, "SmallCNN"),
                                (acc_global, "^-", C_GREEN, "CNN + global max pool")]:
    plt.plot(shifts, acc, style, color=color, lw=2.5, label=name)
plt.xlabel("pixels moved right"); plt.ylabel("test accuracy"); plt.ylim(0, 1.02)
plt.legend(); plt.title("Moved digits")
plt.show()
print("global:  " + "".join(f"{a:6.2f}" for a in acc_global))
No description has been provided for this image
global:    0.97  0.96  0.95  0.95  0.96  0.95  0.93  0.92  0.89

With 24K parameters against SmallCNN's 810K, the global-pool network does about as well on centered digits and barely drops when the digit moves. Every training digit was centered, so the invariance comes from the architecture, not from the data. (BatchNorm, from Lectures 14–15, keeps its training stable in 2 epochs.)


Step 7: What Did the Kernels Learn?¶

Layer 1: eight 3×3 kernels¶

cnn.features[0].weight has shape (8, 1, 3, 3). Each kernel acts on pixels, so we can draw it as a tiny image: blue is negative, red is positive.

In [141]:
W1 = cnn.features[0].weight.detach().cpu()[:, 0]          # (8, 3, 3)
lim = W1.abs().max()
show_row(list(W1), [f"kernel {i}" for i in range(8)], cmap="RdBu_r", vmin=-lim, vmax=lim)
show_row(list(line_kernels.values()), [f"hand-made {n}" for n in line_kernels], cmap="RdBu_r", vmin=-2, vmax=2)
No description has been provided for this image
No description has been provided for this image

Each learned kernel's response to the 9, after ReLU:

In [142]:
with torch.no_grad():
    maps = F.relu(cnn.features[0](nine_real.to(device)))[0].cpu()     # (8, 28, 28)
show_row(list(maps), [f"kernel {i}" for i in range(8)])
No description has been provided for this image

The failure: a layer-2 kernel is not a picture¶

cnn.features[3].weight has shape (64, 8, 3, 3). One layer-2 kernel is eight 3×3 slices, one per layer-1 feature map. Drawn as images, the slices say little: they weight feature maps, not pixels.

In [143]:
W2 = cnn.features[3].weight.detach().cpu()
print("layer-2 weight shape:", tuple(W2.shape))
lim = W2[0].abs().max()
show_row(list(W2[0]), [f"slice {i}" for i in range(8)], cmap="RdBu_r", vmin=-lim, vmax=lim)
layer-2 weight shape: (64, 8, 3, 3)
No description has been provided for this image

Which patches excite a layer-2 channel?¶

Run the 10,000 test digits through the network, record each layer-2 channel's strongest response in each image, and crop the input patch under it. A layer-2 unit sees an 8×8 patch of the input: its receptive field.

In [144]:
@torch.no_grad()
def top_patches(model, dataset, channels, k=8, rf=8):
    """For each channel, the k input patches (rf x rf) with the largest layer-2 response."""
    model.eval()
    best = {c: [] for c in channels}
    for xb, _ in DataLoader(dataset, batch_size=1000):
        a = model.features[:5](xb.to(device)).cpu()            # conv2 + ReLU: (B, 64, 14, 14)
        flat = a.flatten(2)
        vals, idx = flat.max(2)                                 # strongest position per image and channel
        xp = F.pad(xb, (4, 4, 4, 4))
        for c in channels:
            for b in vals[:, c].topk(k).indices:
                i, j = divmod(idx[b, c].item(), 14)
                best[c].append((vals[b, c].item(), xp[b, 0, 2 * i + 1: 2 * i + 1 + rf, 2 * j + 1: 2 * j + 1 + rf]))
    return {c: [p for _, p in sorted(best[c], key=lambda t: -t[0])[:k]] for c in channels}
In [145]:
channels = [0, 5, 12, 21, 30, 41, 50, 63]
patches = top_patches(cnn, test_ds, channels)
fig, axes = plt.subplots(len(channels), 8, figsize=(8, len(channels) * 1.05))
for r, c in enumerate(channels):
    for s in range(8):
        axes[r, s].imshow(patches[c][s], cmap="gray", vmin=0, vmax=1); axes[r, s].axis("off")
    axes[r, 0].set_title(f"channel {c}", fontsize=10, loc="left")
plt.tight_layout(); plt.show()
No description has been provided for this image

Activation maximization: the input a channel wants¶

Instead of searching the data, optimize the input. Start from a gray image with a little noise and run gradient ascent on the pixels to make one channel's response at the center as large as possible. The weights stay fixed; only the input changes. (We maximize the response before the ReLU: after it, a unit that starts at 0 has zero gradient, the dead-unit problem from Lecture 12.)

In [146]:
def maximize_channel(model, c, tv=0.0, steps=200, lr=0.05, l2=1e-2):
    model.eval()
    torch.manual_seed(0)
    x = (0.5 + 0.1 * torch.randn(1, 1, 28, 28, device=device)).requires_grad_(True)
    opt = torch.optim.Adam([x], lr=lr)
    for _ in range(steps):
        opt.zero_grad()
        act = model.features[:4](x)[0, c, 7, 7]               # center unit of channel c, before ReLU
        smooth = (x[..., 1:, :] - x[..., :-1, :]).abs().sum() + (x[..., :, 1:] - x[..., :, :-1]).abs().sum()
        (-act + l2 * ((x - 0.5) ** 2).sum() + tv * smooth).backward()   # minimize the negative = ascend
        opt.step()
        x.data.clamp_(0, 1)
    return x.detach().cpu()[0, 0, 11:19, 11:19]                # its 8x8 receptive field
In [147]:
torch.manual_seed(189)
rows = {"no penalty": [maximize_channel(cnn, c) for c in channels],
        "smoothness penalty": [maximize_channel(cnn, c, tv=0.02) for c in channels],
        "top data patch": [patches[c][0] for c in channels]}
fig, axes = plt.subplots(3, 8, figsize=(12, 5))
for r, (name, imgs) in enumerate(rows.items()):
    for k, img in enumerate(imgs):
        axes[r, k].imshow(img, cmap="gray", vmin=0, vmax=1); axes[r, k].set_xticks([]); axes[r, k].set_yticks([])
        if r == 0: axes[r, k].set_title(f"channel {channels[k]}", fontsize=11)
    axes[r, 0].set_ylabel(name, fontsize=11)
plt.tight_layout(); plt.show()
No description has been provided for this image

Without a penalty the optimized input is speckled: the unit responds to high-frequency patterns that never occur in handwriting. A small penalty on differences between neighboring pixels gives strokes that match the data patches below them.


Step 8: Where Is the Network Looking?¶

Occlusion¶

Cover part of the digit with a black square and measure how much the probability of the true class drops. Slide the square over every position.

In [148]:
@torch.no_grad()
def occlusion_map(model, x, target, patch=8, stride=2, fill=0.0):
    """Drop in P(target) when a patch x patch square is covered, at each position.
    Returns the base probability, the drop per square, and a per-pixel map
    (each pixel gets the average drop over the squares that cover it)."""
    model.eval()
    base = F.softmax(model(x.to(device)), 1)[0, target].item()
    H = x.shape[-1]
    starts = range(0, H - patch + 1, stride)
    grid = torch.zeros(len(starts), len(starts))
    heat, count = torch.zeros(H, H), torch.zeros(H, H)
    for i, r in enumerate(starts):
        for j, c in enumerate(starts):
            xo = x.clone(); xo[..., r:r + patch, c:c + patch] = fill
            grid[i, j] = base - F.softmax(model(xo.to(device)), 1)[0, target].item()
            heat[r:r + patch, c:c + patch] += grid[i, j]; count[r:r + patch, c:c + patch] += 1
    return base, grid, heat / count.clamp(min=1)
In [149]:
x9, y9 = test_ds[7]                                             # a 9
base, grid, heat = occlusion_map(cnn, x9[None], y9)
i, j = divmod(grid.argmax().item(), grid.shape[1])              # the most damaging square
xo = x9.clone(); xo[..., 2 * i:2 * i + 8, 2 * j:2 * j + 8] = 0
with torch.no_grad():
    p_o = F.softmax(cnn(xo[None].to(device)), 1)[0].cpu()

fig, axes = plt.subplots(1, 3, figsize=(10, 3.6))
axes[0].imshow(x9[0], cmap="gray"); axes[0].set_title(f"P({y9}) = {base:.3f}")
axes[1].imshow(x9[0], cmap="gray"); im = axes[1].imshow(heat, cmap="magma", alpha=0.6, vmin=0, vmax=1)
axes[1].set_title("drop in P(9) when covered"); plt.colorbar(im, ax=axes[1], fraction=0.046)
axes[2].imshow(xo[0], cmap="gray"); axes[2].set_title(f"worst square: predicts {p_o.argmax()} ({p_o.max():.2f})")
for ax in axes: ax.axis("off")
plt.tight_layout(); plt.show()
No description has been provided for this image

Saliency¶

One backward pass instead of hundreds of forward passes: the gradient of the class score with respect to each input pixel. A large $|\partial y_k / \partial x_{ij}|$ means a small change to that pixel moves the score most.

In [150]:
def saliency(model, x, target):
    model.eval()
    x = x.clone().to(device).requires_grad_(True)
    model(x)[0, target].backward()
    return x.grad.abs().amax(1)[0].cpu()                       # max over color channels

sal = saliency(cnn, x9[None], y9)
show_row([x9[0], sal], ["input", "saliency |d score / d pixel|"], size=3)
No description has been provided for this image

Step 9: AlexNet on a Photo (Demo 4)¶

AlexNet (2012) with its ImageNet weights. The first run downloads about 240 MB: run this section once before lecture. Note that torchvision's AlexNet has 64 first-layer kernels, not the paper's 96 (it is the single-GPU version).

In [151]:
weights = tv.models.AlexNet_Weights.IMAGENET1K_V1
alexnet = tv.models.alexnet(weights=weights).to(device).eval()
categories = weights.meta["categories"]
preprocess = weights.transforms()                               # resize, center crop 224, normalize

photo = Image.open("pishi.png").convert("RGB")
x_cat = preprocess(photo)[None].to(device)                      # (1, 3, 224, 224)
MEAN, STD = torch.tensor([0.485, 0.456, 0.406]), torch.tensor([0.229, 0.224, 0.225])
cat_img = (x_cat[0].cpu() * STD[:, None, None] + MEAN[:, None, None]).permute(1, 2, 0).clamp(0, 1)
In [160]:
summary(alexnet, input_size=(1, 3, 224, 224), col_names=("output_size", "num_params"), depth=2)
Out[160]:
==========================================================================================
Layer (type:depth-idx)                   Output Shape              Param #
==========================================================================================
AlexNet                                  [1, 1000]                 --
├─Sequential: 1-1                        [1, 256, 6, 6]            --
│    └─Conv2d: 2-1                       [1, 64, 55, 55]           23,296
│    └─ReLU: 2-2                         [1, 64, 55, 55]           --
│    └─MaxPool2d: 2-3                    [1, 64, 27, 27]           --
│    └─Conv2d: 2-4                       [1, 192, 27, 27]          307,392
│    └─ReLU: 2-5                         [1, 192, 27, 27]          --
│    └─MaxPool2d: 2-6                    [1, 192, 13, 13]          --
│    └─Conv2d: 2-7                       [1, 384, 13, 13]          663,936
│    └─ReLU: 2-8                         [1, 384, 13, 13]          --
│    └─Conv2d: 2-9                       [1, 256, 13, 13]          884,992
│    └─ReLU: 2-10                        [1, 256, 13, 13]          --
│    └─Conv2d: 2-11                      [1, 256, 13, 13]          590,080
│    └─ReLU: 2-12                        [1, 256, 13, 13]          --
│    └─MaxPool2d: 2-13                   [1, 256, 6, 6]            --
├─AdaptiveAvgPool2d: 1-2                 [1, 256, 6, 6]            --
├─Sequential: 1-3                        [1, 1000]                 --
│    └─Dropout: 2-14                     [1, 9216]                 --
│    └─Linear: 2-15                      [1, 4096]                 37,752,832
│    └─ReLU: 2-16                        [1, 4096]                 --
│    └─Dropout: 2-17                     [1, 4096]                 --
│    └─Linear: 2-18                      [1, 4096]                 16,781,312
│    └─ReLU: 2-19                        [1, 4096]                 --
│    └─Linear: 2-20                      [1, 1000]                 4,097,000
==========================================================================================
Total params: 61,100,840
Trainable params: 61,100,840
Non-trainable params: 0
Total mult-adds (M): 714.68
==========================================================================================
Input size (MB): 0.60
Forward/backward pass size (MB): 3.95
Params size (MB): 244.40
Estimated Total Size (MB): 248.96
==========================================================================================
In [152]:
with torch.no_grad():
    probs = F.softmax(alexnet(x_cat), 1)[0].cpu()
top = probs.topk(5)
for p, k in zip(top.values, top.indices):
    print(f"{p:.3f}  {categories[k]}")
target = top.indices[0].item()
plt.imshow(cat_img); plt.axis("off"); plt.title(f"AlexNet: {categories[target]}"); plt.show()
0.199  Eskimo dog
0.185  Chihuahua
0.086  Siberian husky
0.082  Japanese spaniel
0.067  Border collie
No description has been provided for this image

AlexNet's trick: random crops and flips¶

During training, each image was seen as a random 224×224 crop of the 256×256 image, sometimes mirrored. The label stays the same, so the network has to recognize the cat wherever it sits in the frame.

In [153]:
augment = transforms.Compose([transforms.Resize(256), transforms.CenterCrop(256),
                              transforms.RandomCrop(224), transforms.RandomHorizontalFlip()])
torch.manual_seed(189)
show_row([augment(photo) for _ in range(5)], [f"crop {i + 1}" for i in range(5)], size=2.6)
No description has been provided for this image

Layer 1: 64 kernels of size 11×11×3¶

In [154]:
W = alexnet.features[0].weight.detach().cpu()                   # (64, 3, 11, 11)
W = (W - W.amin((1, 2, 3), keepdim=True)) / (W.amax((1, 2, 3), keepdim=True) - W.amin((1, 2, 3), keepdim=True))
grid = tv.utils.make_grid(W, nrow=8, padding=1, pad_value=1).permute(1, 2, 0)
plt.figure(figsize=(6, 6)); plt.imshow(grid); plt.axis("off")
plt.title("AlexNet layer-1 kernels"); plt.show()
No description has been provided for this image

Feature maps: what layers 1 and 2 respond to in this photo¶

In [155]:
def feature_maps(model, x, layer, n=16):
    """The first n feature maps of model.features[layer], after ReLU."""
    with torch.no_grad():
        a = model.features[:layer + 2](x)[0].cpu()               # layer + its ReLU
    return [a[i] for i in range(n)]

for layer in [0, 3]:
    maps = feature_maps(alexnet, x_cat, layer)
    show_row(maps[:8], [f"L{layer} map {i}" for i in range(8)], cmap="viridis", size=1.9)
No description has been provided for this image
No description has been provided for this image

Occlusion: cover a 32×32 square, measure the drop in the top class¶

In [156]:
base, _, heat = occlusion_map(alexnet, x_cat, target, patch=32, stride=16)   # same function as Step 8
plt.imshow(cat_img); plt.imshow(heat, cmap="magma", alpha=0.55); plt.axis("off")
plt.title(f"Occlusion: drop in P({categories[target]}), base {base:.2f}"); plt.colorbar(); plt.show()
No description has been provided for this image

Saliency and guided backpropagation¶

In [157]:
sal = saliency(alexnet, x_cat, target)
plt.imshow(sal, cmap="gray"); plt.axis("off"); plt.title("Saliency"); plt.show()
No description has been provided for this image

Saliency is noisy because negative gradients pass back through every ReLU. Guided backpropagation changes the backward pass of each ReLU: it passes a gradient only where the forward input was positive and the gradient itself is positive.

In [158]:
class GuidedReLU(torch.autograd.Function):
    @staticmethod
    def forward(ctx, a):
        ctx.save_for_backward(a)
        return a.clamp(min=0)

    @staticmethod
    def backward(ctx, grad):
        a, = ctx.saved_tensors
        return grad.clamp(min=0) * (a > 0)                      # ordinary ReLU: grad * (a > 0)


class GuidedReLUModule(nn.Module):
    def forward(self, a):
        return GuidedReLU.apply(a)
In [159]:
guided = copy.deepcopy(alexnet)
for parent in [guided.features, guided.classifier]:
    for i, m in enumerate(parent):
        if isinstance(m, nn.ReLU):
            parent[i] = GuidedReLUModule()

x = x_cat.clone().requires_grad_(True)
guided(x)[0, target].backward()
g = x.grad[0].cpu()
g = ((g - g.min()) / (g.max() - g.min())).permute(1, 2, 0)
show_row([cat_img, sal, g], ["input", "saliency", "guided backprop"], size=3.6)
No description has been provided for this image
In [ ]: