8.2 Image classification

A convolutional network slides small learned filters over an image, so a pattern learned in one place is recognised everywhere. Stacked in layers, the filters build up from strokes to shapes to objects, and the network learns them all from labelled examples.

Code: data, style and a small convolutional network used in this chapter
import warnings
from collections.abc import Callable

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from scipy.ndimage import shift as shift_image
from scipy.signal import correlate2d
from sklearn import datasets
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

from statly import style

style.use()
rng = np.random.default_rng(1)
warnings.filterwarnings("ignore")

digits = datasets.load_digits()  # 1,797 handwritten digits, 8 × 8 pixels each
images = digits.images / 16  # darkness from 0 to 1
labels = digits.target
train_idx, test_idx = train_test_split(np.arange(len(labels)), test_size=0.3, random_state=1)
flower = datasets.load_sample_images().images[1]  # a photo that ships with scikit-learn


def patches(batch: np.ndarray) -> np.ndarray:
    """Every 3 × 3 neighbourhood of every pixel (zero padded): shape (images, 64, 9)."""
    padded = np.pad(batch, ((0, 0), (1, 1), (1, 1)))
    rows = [padded[:, a : a + 8, b : b + 8] for a in range(3) for b in range(3)]
    return np.stack(rows, axis=-1).reshape(len(batch), 64, 9)


class SmallCNN:
    """Convolution (3 × 3 filters) → ReLU → 2 × 2 max pooling → softmax over 10 digits."""

    def __init__(self, filters: int = 8, seed: int = 0) -> None:
        local = np.random.default_rng(seed)
        self.k = filters
        self.W = local.normal(0, 0.5, (9, filters))  # the filters, one per column
        self.b = np.zeros(filters)
        self.V = local.normal(0, 0.1, (16 * filters, 10))  # pooled maps to class scores
        self.c = np.zeros(10)

    def forward(self, batch: np.ndarray) -> tuple:
        """All intermediate results for a batch of 8 × 8 images."""
        P = patches(batch)
        Z = P @ self.W + self.b  # filter responses
        A = np.maximum(Z, 0).reshape(-1, 4, 2, 4, 2, self.k)  # feature maps in 2 × 2 blocks
        M = A.max(axis=(2, 4))  # max pooling
        scores = M.reshape(len(batch), -1) @ self.V + self.c
        return P, Z, A, M, scores

    def proba(self, batch: np.ndarray) -> np.ndarray:
        """Softmax probabilities of the 10 digits."""
        scores = self.forward(batch)[-1]
        e = np.exp(scores - scores.max(axis=1, keepdims=True))
        return e / e.sum(axis=1, keepdims=True)

    def accuracy(self, batch: np.ndarray, truth: np.ndarray) -> float:
        """Share of images whose most likely digit is right."""
        return float((self.proba(batch).argmax(axis=1) == truth).mean())

    def gradients(self, batch: np.ndarray, truth: np.ndarray) -> dict[str, np.ndarray]:
        """Backpropagation of the cross-entropy loss, to the weights and to the pixels."""
        P, Z, A, M, _ = self.forward(batch)
        g = self.proba(batch)
        g[np.arange(len(truth)), truth] -= (
            1  # softmax + cross-entropy: p − 1 for the true class
        )
        g /= len(truth)
        grad = {"V": M.reshape(len(batch), -1).T @ g, "c": g.sum(axis=0)}
        gM = (g @ self.V.T).reshape(M.shape)
        winner = A == M[:, :, None, :, None, :]  # only the largest value in a block passes back
        gZ = (winner * gM[:, :, None, :, None, :]).reshape(len(batch), 64, self.k) * (Z > 0)
        grad["W"] = np.einsum("npi,npk->ik", P, gZ)
        grad["b"] = gZ.sum(axis=(0, 1))
        gP = (gZ @ self.W.T).reshape(len(batch), 8, 8, 3, 3)
        pixels = np.zeros((len(batch), 10, 10))
        for a in range(3):
            for b in range(3):
                pixels[:, a : a + 8, b : b + 8] += gP[:, :, :, a, b]
        grad["x"] = pixels[:, 1:9, 1:9]
        return grad

    def fit(
        self,
        batch: np.ndarray,
        truth: np.ndarray,
        epochs: int = 30,
        rate: float = 0.05,
        train: tuple[str, ...] = ("W", "b", "V", "c"),
        watch: Callable[["SmallCNN"], None] | None = None,
    ) -> "SmallCNN":
        """Minibatch gradient descent with momentum; `watch` is called after every epoch."""
        local = np.random.default_rng(0)
        velocity = {name: 0.0 for name in train}
        for _ in range(epochs):
            order = local.permutation(len(truth))
            for start in range(0, len(order), 32):
                rows = order[start : start + 32]
                grad = self.gradients(batch[rows], truth[rows])
                for name in train:
                    velocity[name] = 0.9 * velocity[name] - rate * grad[name]
                    setattr(self, name, getattr(self, name) + velocity[name])
            if watch:
                watch(self)
        return self


def show_image(
    ax: Axes, image: np.ndarray, title: str, cmap: str = "gray_r", limit: float | None = None
) -> None:
    """An image without axes."""
    if limit is None:
        ax.imshow(image, cmap=cmap)
    else:
        ax.imshow(image, cmap=cmap, vmin=-limit, vmax=limit)
    ax.set_title(title, fontsize=10)
    ax.set_axis_off()

8.2.1 An image is a grid of numbers

To a computer a photo is a table of numbers: one row per line of pixels, one column per pixel, and for colour three layers, the channels, for the amounts of red, green and blue. Each number runs from 0 (none) to 255 (full).

Code
ROW, COL = 210, 300  # top-left corner of the zoomed patch


def plot_patch(ax: Axes) -> None:
    """A tiny patch of the photo, with each pixel's red value."""
    patch = flower[ROW : ROW + 6, COL : COL + 6]
    ax.imshow(patch)
    for i in range(6):
        for j in range(6):
            ax.text(j, i, patch[i, j, 0], ha="center", va="center", fontsize=7, color="white")
    ax.set_title("6 × 6 pixels, red values", fontsize=10)
    ax.set_axis_off()


fig, axes = plt.subplots(1, 5, figsize=(11, 2.6))
axes[0].imshow(flower)
axes[0].add_patch(plt.Rectangle((COL, ROW), 6, 6, fill=False, color="white", lw=1.5))
axes[0].set_title("the photo", fontsize=10)
axes[0].set_axis_off()
for k, (ax, channel, cmap) in enumerate(
    zip(axes[1:4], ("red", "green", "blue"), ("Reds", "Greens", "Blues"))
):
    show_image(ax, flower[..., k], channel, cmap=cmap)
plot_patch(axes[4])
fig.tight_layout()
plt.show()
Figure 8.2.1: A photo of 427 × 640 pixels (left) is three tables of numbers, one per colour channel (middle three, darker meaning more of that colour). Right: a 6 × 6 patch of the photo (white square, left), with the red channel’s values written in. The photo is 819,840 numbers in all.

Image classification asks: given these numbers, which label fits? Is it a 3 or an 8, a cat or a dog, a healthy or a diseased leaf? This chapter uses the 8 × 8 handwritten digits of earlier chapters, small enough to train a network on the spot, but the ideas are the same for photos with millions of pixels.

8.2.2 Convolution: one small filter, used everywhere

A plain network (Chapter 8.1) gives every pixel its own weight to every hidden unit. It has no idea that pixels next to each other belong together, and a stroke learned in one corner means nothing in another. A convolutional layer instead slides a small filter, say 3 × 3 weights, over the whole image. At each position it multiplies the filter with the patch of pixels under it and adds up:

feature(i,j)=∑a,bfilter(a,b)⋅image(i+a,j+b) \text{feature}(i, j) = \sum_{a, b} \text{filter}(a, b) \cdot \text{image}(i + a, j + b)

The result is a feature map: large wherever the patch looks like the filter. The same nine weights are used at every position, so the layer finds the pattern wherever it occurs.

Code
FILTERS = {
    "vertical": np.array([[-1, 2, -1], [-1, 2, -1], [-1, 2, -1]]),
    "horizontal": np.array([[-1, -1, -1], [2, 2, 2], [-1, -1, -1]]),
    "diagonal": np.array([[-1, -1, 2], [-1, 2, -1], [2, -1, -1]]),
}
example = images[np.where(labels == 4)[0][0]]

fig, axes = plt.subplots(2, 4, figsize=(9.5, 4.6), gridspec_kw={"height_ratios": [1, 2.4]})
axes[0, 0].set_axis_off()
show_image(axes[1, 0], example, "the image")
for k, (name, kernel) in enumerate(FILTERS.items(), start=1):
    show_image(axes[0, k], kernel, f"{name} filter", cmap="RdBu_r", limit=2)
    feature = np.maximum(0, correlate2d(example, kernel, mode="same"))  # ReLU of the response
    show_image(axes[1, k], feature, "feature map", cmap="Greens")
fig.tight_layout()
plt.show()
Figure 8.2.2: A digit (left) and three 3 × 3 filters (small squares, red positive and blue negative weights), each slid over the image. The feature maps light up where the image looks like the filter: vertical strokes, horizontal strokes, and the lower-left to upper-right diagonal. These filters are set by hand to show the idea; Section 8.2.4 shows filters a network learned.

A real network has many filters per layer, and the feature maps pass through a ReLU (Chapter 7.1). The next layer applies its own filters to the stack of feature maps, so it can combine strokes into loops and corners, and the layer after that combines those into digits or faces.

8.2.3 Pooling

A pooling layer shrinks each feature map by keeping only the largest value in each small block, say 2 × 2. The map gets four times smaller, and a stroke that moves by a pixel still lights up the same pooled cell. Pooling is what makes a convolutional network tolerant of small shifts.

Code
vertical = np.maximum(0, correlate2d(example, FILTERS["vertical"], mode="same"))


def max_pool(feature: np.ndarray, size: int = 2) -> np.ndarray:
    """The largest value in each size × size block."""
    h, w = feature.shape
    blocks = feature[: h // size * size, : w // size * size]
    return blocks.reshape(h // size, size, w // size, size).max(axis=(1, 3))


fig, axes = style.subplots(2, height=3, width=6.5)
show_image(axes[0], vertical, "feature map, 8 × 8", cmap="Greens")
for k in range(1, 4):
    axes[0].axhline(2 * k - 0.5, color=style.INK, lw=1)
    axes[0].axvline(2 * k - 0.5, color=style.INK, lw=1)
show_image(axes[1], max_pool(vertical), "after pooling, 4 × 4", cmap="Greens")
fig.tight_layout()
plt.show()
Figure 8.2.3: Max pooling of the vertical-stroke feature map. Left: the 8 × 8 map with the 2 × 2 blocks outlined. Right: the 4 × 4 pooled map, each cell the largest value in its block.

8.2.4 Training a network

A small but complete convolutional network: 8 filters of 3 × 3, a ReLU, 2 × 2 max pooling, and a softmax layer from the 8 pooled 4 × 4 maps to the 10 digits. That is 72 filter weights and 1,290 weights in the last layer, all learned by gradient descent on the cross-entropy loss (Chapter 8.1), from 1,257 training digits.

Code
history = {"train": [], "test": []}


def record(model: SmallCNN) -> None:
    """Accuracy on training and held-out digits after an epoch."""
    history["train"].append(model.accuracy(images[train_idx], labels[train_idx]))
    history["test"].append(model.accuracy(images[test_idx], labels[test_idx]))


cnn = SmallCNN().fit(images[train_idx], labels[train_idx], watch=record)


def plot_architecture(ax: Axes) -> None:
    """Boxes for the layers, from image to probabilities."""
    layers = [
        ("digit\n8 × 8", 1.6, style.GREY),
        ("8 feature\nmaps 8 × 8", 1.6, style.GREEN),
        ("pooled\n8 × 4 × 4", 1.0, style.GREEN),
        ("10 digit\nprobabilities", 2.2, style.CORAL),
    ]
    for i, (name, height, color) in enumerate(layers):
        x = i * 1.5
        ax.add_patch(plt.Rectangle((x, -height / 2), 0.9, height, color=color, alpha=0.5))
        ax.text(x + 0.45, -height / 2 - 0.15, name, ha="center", va="top", fontsize=8.5)
        if i:
            arrow = dict(arrowstyle="->", color=style.INK)
            ax.annotate("", xy=(x, 0), xytext=(x - 0.6, 0), arrowprops=arrow)
    for x, text in ((1.2, "filters\n+ ReLU"), (2.7, "max\npool"), (4.2, "weights\n+ softmax")):
        ax.text(x, 0.15, text, ha="center", va="bottom", fontsize=7.5, color=style.INK)
    ax.set(xlim=(-0.2, 5.6), ylim=(-2.4, 1.5))
    ax.set_axis_off()


def plot_learning(ax: Axes) -> None:
    """Accuracy per epoch on training and held-out digits."""
    epochs = np.arange(1, len(history["train"]) + 1)
    ax.plot(epochs, history["train"], color=style.BLUE, lw=2, label="training digits")
    ax.plot(epochs, history["test"], color=style.CORAL, lw=2, label="held-out digits")
    final = f"held-out after {epochs[-1]} epochs: {history['test'][-1]:.1%}"
    style.note(ax, final, x=0.25, y=0.15, color=style.INK)
    ax.yaxis.set_major_formatter(style.PERCENT)
    ax.legend(fontsize=8, loc="center right")
    ax.set(xlabel="epoch", ylabel="accuracy", ylim=(0.8, 1.005))


fig = plt.figure(figsize=(10.5, 3.2))
grid = fig.add_gridspec(1, 2, width_ratios=[1.2, 1])
plot_architecture(fig.add_subplot(grid[0]))
plot_learning(fig.add_subplot(grid[1]))
fig.tight_layout()
plt.show()
Figure 8.2.4: Left: the network, from an 8 × 8 digit to 8 feature maps, pooled to 4 × 4, to 10 digit probabilities. Right: accuracy on the training digits and on 540 held-out digits after each pass through the training data (an epoch).

What did it learn? Its filters started as random numbers. After training they are small stroke and edge detectors, chosen because they help tell the digits apart:

Code
digit = images[test_idx[3]]
maps = np.maximum(0, patches(digit[None]) @ cnn.W + cnn.b)[0].reshape(8, 8, cnn.k)
largest = np.abs(cnn.W).max()

fig, axes = plt.subplots(2, cnn.k + 1, figsize=(11, 2.9))
axes[0, 0].set_axis_off()
show_image(axes[1, 0], digit, "digit")
for k in range(cnn.k):
    weights = cnn.W[:, k].reshape(3, 3)
    show_image(axes[0, k + 1], weights, f"filter {k + 1}", cmap="RdBu_r", limit=largest)
    show_image(axes[1, k + 1], maps[..., k], "", cmap="Greens")
fig.tight_layout()
plt.show()
Figure 8.2.5: Top: the 8 filters the network learned (red positive, blue negative weights). Bottom: their feature maps for one held-out digit (left). Each filter responds to a different part of the shape, such as one edge of a stroke or a bar in one direction.

Large networks repeat this pattern many times: dozens of layers, hundreds of filters each. Early layers learn edges and colours, middle layers textures and parts, late layers whole objects. Each layer’s filters see a wider area of the image than the layer before.

8.2.5 Why it helps: shifted digits

The shared filters and the pooling make a convolutional network tolerate shifts that a plain model has never seen. Train on the digits as they are, then test on digits moved by one pixel:

Code
DIRECTIONS = [(1, 0), (-1, 0), (0, 1), (0, -1)]


def shifted(batch: np.ndarray) -> np.ndarray:
    """Each image moved one pixel in a random direction."""
    moves = rng.integers(0, 4, len(batch))
    return np.array([shift_image(img, DIRECTIONS[m], order=0) for img, m in zip(batch, moves)])


def conv_features(batch: np.ndarray) -> np.ndarray:
    """The three hand-made filter responses, ReLU, pooled 2 × 2 and flattened."""
    rows = []
    for img in batch:
        maps = [
            max_pool(np.maximum(0, correlate2d(img, kernel, mode="same")))
            for kernel in FILTERS.values()
        ]
        rows.append(np.concatenate([m.ravel() for m in maps]))
    return np.array(rows)


def raw_pixels(batch: np.ndarray) -> np.ndarray:
    """The 64 pixels of each image as they are."""
    return batch.reshape(len(batch), -1)


test_images, test_labels = images[test_idx], labels[test_idx]
test_shifted = shifted(test_images)


def logistic_scores(
    featurise: Callable[[np.ndarray], np.ndarray],
    train_images: np.ndarray,
    train_labels: np.ndarray,
) -> tuple[float, float]:
    """Accuracy on the test digits as they are and shifted, for logistic regression on features."""
    model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=3000, C=0.5))
    model.fit(featurise(train_images), train_labels)
    return (
        model.score(featurise(test_images), test_labels),
        model.score(featurise(test_shifted), test_labels),
    )


def network_scores(model: SmallCNN) -> tuple[float, float]:
    """Accuracy of a network on the test digits as they are and shifted."""
    return model.accuracy(test_images, test_labels), model.accuracy(test_shifted, test_labels)


augmented = np.concatenate(
    [images[train_idx], shifted(images[train_idx]), shifted(images[train_idx])]
)
augmented_labels = np.tile(labels[train_idx], 3)
RESULTS = [
    (
        "pixels, logistic regression",
        logistic_scores(raw_pixels, images[train_idx], labels[train_idx]),
    ),
    (
        "hand-made filters, pooled",
        logistic_scores(conv_features, images[train_idx], labels[train_idx]),
    ),
    ("trained network", network_scores(cnn)),
    ("pixels, with augmentation", logistic_scores(raw_pixels, augmented, augmented_labels)),
    (
        "network, with augmentation",
        network_scores(SmallCNN().fit(augmented, augmented_labels, epochs=15)),
    ),
]


def plot_results(ax: Axes) -> None:
    """Paired bars: accuracy on original and shifted digits."""
    for i, (name, (plain, moved)) in enumerate(RESULTS):
        first = i == 0
        ax.barh(
            i - 0.18,
            plain,
            height=0.36,
            color=style.BLUE,
            alpha=0.4,
            label="as they are" if first else None,
        )
        ax.barh(
            i + 0.18,
            moved,
            height=0.36,
            color=style.BLUE,
            alpha=0.9,
            label="moved one pixel" if first else None,
        )
        ax.text(moved + 0.005, i + 0.18, f"{moved:.0%}", va="center", fontsize=9)
    ax.set_yticks(range(len(RESULTS)), [name for name, _ in RESULTS])
    ax.invert_yaxis()
    ax.xaxis.set_major_formatter(style.PERCENT)
    ax.legend(fontsize=8, loc="lower right", bbox_to_anchor=(1, 1.0), ncol=2)
    ax.set(xlabel="accuracy on held-out digits", xlim=(0, 1.05))


fig, ax = plt.subplots(figsize=(8, 3.4))
plot_results(ax)
plt.show()
Figure 8.2.6: Accuracy on held-out digits as they are (light) and moved one pixel in a random direction (dark). A logistic regression on the raw pixels loses much of its accuracy when the digits move. On the three hand-made filter maps, pooled, it loses much less, and so does the trained network. Training on shifted copies of the training digits (data augmentation) helps both kinds of model, and most of all the network.

Data augmentation is standard practice: real networks are trained on shifted, rotated, flipped, cropped and recoloured copies of every image, so that they learn that these changes do not change the label.

8.2.6 Where it goes wrong

Accuracy is one number. The confusion matrix (Chapter 6.1) shows which digits are mistaken for which, and looking at the mistakes themselves is often the quickest way to learn what a model cannot do.

Code
probabilities = cnn.proba(test_images)
predicted = probabilities.argmax(axis=1)
wrong = np.flatnonzero(predicted != test_labels)


def plot_confusion(ax: Axes) -> None:
    """Counts of true against predicted digits, off-diagonal cells highlighted."""
    table = np.zeros((10, 10), int)
    np.add.at(table, (test_labels, predicted), 1)
    off = table * (1 - np.eye(10, dtype=int))
    ax.imshow(off, cmap="Reds", vmin=0, vmax=max(off.max(), 1))
    for i in range(10):
        for j in range(10):
            if table[i, j]:
                color = style.BLUE if i == j else style.INK
                ax.text(j, i, table[i, j], ha="center", va="center", fontsize=7.5, color=color)
    ax.set(xticks=range(10), yticks=range(10), xlabel="network says", ylabel="true digit")
    ax.grid(False)


fig = plt.figure(figsize=(10.5, 4))
grid = fig.add_gridspec(2, 6, width_ratios=[3.2, 1, 1, 1, 1, 1])
plot_confusion(fig.add_subplot(grid[:, 0]))
for n, i in enumerate(wrong[:10]):
    title = f"{test_labels[i]} → {predicted[i]} ({probabilities[i].max():.0%})"
    show_image(fig.add_subplot(grid[n // 5, 1 + n % 5]), test_images[i], title)
fig.tight_layout()
plt.show()
Figure 8.2.7: Left: confusion matrix of the trained network on the 540 held-out digits; the diagonal holds the correct answers and the few mistakes sit off it (red). Right: some of the misclassified digits, with the true label, the network’s answer and its probability. Several are hard for a person too.

8.2.7 What does the network look at?

A network does not explain itself, but it can be probed. Occlusion covers one part of the image at a time and watches how the probability of the right answer changes. Where covering hurts, the network was looking.

Code
def occlusion_map(image: np.ndarray, digit: int) -> np.ndarray:
    """Drop in the probability of `digit` when each 2 × 2 block is erased."""
    base = cnn.proba(image[None])[0, digit]
    drops = np.zeros((8, 8))
    for i in range(0, 8, 2):
        for j in range(0, 8, 2):
            covered = image.copy()
            covered[i : i + 2, j : j + 2] = 0
            drops[i : i + 2, j : j + 2] = base - cnn.proba(covered[None])[0, digit]
    return drops


right = predicted == test_labels
chosen = [np.flatnonzero(right & (test_labels == d))[0] for d in (0, 4, 7, 9)]
fig, axes = plt.subplots(2, 4, figsize=(8, 4.2))
for col, i in enumerate(chosen):
    image = test_images[i]
    show_image(axes[0, col], image, f"a {test_labels[i]}")
    drops = np.clip(occlusion_map(image, test_labels[i]), 0, None)
    show_image(axes[1, col], drops, "where covering hurts", cmap="Reds")
fig.tight_layout()
plt.show()
Figure 8.2.8: Occlusion maps for four held-out digits. Each 2 × 2 block of the image was erased in turn; the colour shows how much the probability of the correct digit fell (darker red: fell more). Erasing most of the ink changes little; the network relies on a few telling parts.

Probes like this catch networks that are right for the wrong reason, a famous example being a classifier that recognised wolves by the snow in the background. Gradient-based saliency maps do the same job faster, by asking which pixels the output is most sensitive to.

8.2.8 Little data: transfer learning

Training a large network from scratch needs a great many labelled images. With few, the usual trick is transfer learning: take a network already trained on a large collection, keep its filters, and retrain only the last layer on the new task. The filters, edge and stroke detectors, are useful far beyond the task they were learned on.

Code
low = np.isin(labels[train_idx], range(5))
source = SmallCNN().fit(images[train_idx][low], labels[train_idx][low])
target_train = train_idx[labels[train_idx] >= 5]
target_test = test_idx[labels[test_idx] >= 5]
PER_CLASS = (1, 2, 5, 10, 25, 50)


def few_examples(n: int, seed: int) -> np.ndarray:
    """n training images of each of the digits 5 to 9."""
    local = np.random.default_rng(seed)
    pools = [target_train[labels[target_train] == d] for d in range(5, 10)]
    return np.concatenate([local.choice(pool, n, replace=False) for pool in pools])


def transfer_accuracy(n: int, reuse: bool, repetitions: int = 10) -> float:
    """Mean held-out accuracy on 5 to 9 after training on n examples per digit."""
    scores = []
    for seed in range(repetitions):
        rows = few_examples(n, seed)
        model = SmallCNN(seed=seed)
        trained = ("W", "b", "V", "c")
        if reuse:
            model.W, model.b = source.W.copy(), source.b.copy()
            trained = ("V", "c")
        model.fit(images[rows], labels[rows], epochs=60, train=trained)
        scores.append(model.accuracy(images[target_test], labels[target_test]))
    return float(np.mean(scores))


fig, ax = plt.subplots(figsize=(8, 3.3))
for reuse, color, name in (
    (False, style.CORAL, "from scratch"),
    (True, style.BLUE, "filters from 0 to 4 reused"),
):
    accuracies = [transfer_accuracy(n, reuse) for n in PER_CLASS]
    ax.plot(PER_CLASS, accuracies, "o-", color=color, lw=2, label=name)
ax.set_xscale("log")
ax.set_xticks(PER_CLASS, [str(n) for n in PER_CLASS])
ax.yaxis.set_major_formatter(style.PERCENT)
ax.legend(fontsize=8, loc="lower right")
ax.set(xlabel="labelled examples per digit (log scale)", ylabel="accuracy on new 5s to 9s")
plt.show()
Figure 8.2.9: Learning to tell the digits 5 to 9 apart from only a few labelled examples of each. Red: a network trained from scratch on those few. Blue: the filters of a network trained on the digits 0 to 4, kept as they are, with only the last layer trained on the few new examples. Accuracy on all held-out 5s to 9s, average of 10 repetitions. Here reuse neither helps nor hurts: with 8 small filters and simple 8 × 8 digits, a network finds good filters from a handful of examples.

The small experiment shows when transfer does not pay: when the network is small and the images simple, there is little to transfer. It pays when both are large. A network for photos has millions of weights, which no one could fit to a few hundred labelled images of, say, skin lesions or plant diseases. Trained first on millions of varied photos, its filters already capture edges, textures and shapes, and the few labelled images only need to teach the last layer. The source also has to be broad: a network trained on a narrow task learns to throw away what that task does not need.

8.2.9 Fooling a network

Networks can be fooled by changes a person would ignore. An adversarial example nudges every pixel a little in the direction that most increases the network’s loss: the sign of the gradient with respect to the pixels, which backpropagation gives for free.

xadversarial=x+ε⋅sign(∇xloss(x,y)) x_{\text{adversarial}} = x + \varepsilon \cdot \operatorname{sign}\big(\nabla_x \text{loss}(x, y)\big)

Code
EPSILONS = np.linspace(0, 0.3, 13)


def nudged(batch: np.ndarray, truth: np.ndarray, epsilon: float) -> np.ndarray:
    """The fast gradient sign attack, kept within the 0 to 1 range of the pixels."""
    direction = np.sign(cnn.gradients(batch, truth)["x"])
    return np.clip(batch + epsilon * direction, 0, 1)


def smallest_flip(i: int) -> float:
    """The smallest ε on the grid that changes the network's answer for test image i."""
    image, label = test_images[i : i + 1], test_labels[i : i + 1]
    flips = [e for e in EPSILONS if cnn.proba(nudged(image, label, e))[0].argmax() != label[0]]
    return flips[0] if flips else np.inf


victim = next(i for i in np.flatnonzero(right) if smallest_flip(i) <= 0.1)
victim_image, victim_label = test_images[victim : victim + 1], test_labels[victim : victim + 1]
flip = smallest_flip(victim)
attacked = nudged(victim_image, victim_label, flip)[0]
before, after = cnn.proba(victim_image)[0], cnn.proba(attacked[None])[0]
curve = [cnn.accuracy(nudged(test_images, test_labels, e), test_labels) for e in EPSILONS]

fig = plt.figure(figsize=(10.5, 3))
grid = fig.add_gridspec(1, 4, width_ratios=[1, 1, 1, 2.2])
show_image(fig.add_subplot(grid[0]), victim_image[0], f"{victim_label[0]} ({before.max():.0%})")
nudge = attacked - victim_image[0]
show_image(fig.add_subplot(grid[1]), nudge, f"nudge, ε = {flip:.3f}", cmap="RdBu_r", limit=0.15)
show_image(fig.add_subplot(grid[2]), attacked, f"now {after.argmax()} ({after.max():.0%})")
ax = fig.add_subplot(grid[3])
ax.plot(EPSILONS, curve, "o-", color=style.CORAL, ms=4)
ax.yaxis.set_major_formatter(style.PERCENT)
ax.set(xlabel="size of the nudge ε", ylabel="accuracy", ylim=(0, 1.02))
fig.tight_layout()
plt.show()
Figure 8.2.10: Left three: a held-out digit the network classifies correctly, the nudge (red: darker, blue: lighter; every pixel moved by the same small amount ε), and the result, which the network now calls something else. Right: accuracy on all held-out digits as ε grows. On 8 × 8 digits the nudge is visible; on large photos the same attack works with changes far too small to see.

The lesson is not only about attacks. A network’s decision can rest on patterns very different from the ones people use, and high accuracy on test images from the same source says little about images that differ in ways the training set never showed.

8.2.10 Beyond one label per image

The same building blocks do more than classify. Object detection finds and boxes every object in an image, segmentation labels every pixel (tumour or not, road or not), and vision transformers split an image into patches and treat them like the words of a sentence (Chapter 7.3). Whatever the task, the statistical rules of the earlier parts still apply: hold out test images, prefer test images from a new source (another hospital, another camera), and look at the confusion matrix, not just the accuracy.

8.2.11 In one picture

  • An image is a grid of numbers, with one layer per colour channel.
  • Convolutional layers slide shared filters over the image; pooling keeps the largest value in each block and adds tolerance to shifts.
  • A network learns its filters by gradient descent: early layers find edges and strokes, later ones shapes and objects.
  • Data augmentation and transfer learning stretch scarce labelled images.
  • Probe what a network uses with occlusion or saliency maps.
  • Adversarial examples show how differently from us a network can decide.

Next: sequences and language, from recurrent networks to large language models.