05 · CNNs for Image Classification¶
A plain MLP (Module 09) treats an image as a flat list of pixels, throwing
away the fact that nearby pixels are related. Convolutional neural
networks (CNNs) exploit that spatial structure directly, and are the
backbone of essentially all modern computer vision. This module builds and
trains a small CNN on FashionMNIST with PyTorch.
Loading image data¶
import torch
from torch import nn
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
transform = transforms.Compose([transforms.ToTensor()]) # scales pixels to [0, 1]
train_ds = datasets.FashionMNIST(root="./data", train=True, download=True, transform=transform)
test_ds = datasets.FashionMNIST(root="./data", train=False, download=True, transform=transform)
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)
test_loader = DataLoader(test_ds, batch_size=256, shuffle=False)
image, label = train_ds[0]
print(image.shape, label) # torch.Size([1, 28, 28]) 9 -- 1 channel (grayscale), 28x28
Image tensors are (channels, height, width) — one channel here (grayscale);
color photos would have 3 (RGB).
A small CNN¶
class SmallCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1) # 1 -> 16 feature maps
self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1) # 16 -> 32 feature maps
self.pool = nn.MaxPool2d(2) # halves height & width
self.fc = nn.Linear(32 * 7 * 7, 10) # 10 classes
def forward(self, x):
x = self.pool(torch.relu(self.conv1(x))) # 28x28 -> 14x14
x = self.pool(torch.relu(self.conv2(x))) # 14x14 -> 7x7
x = x.flatten(1) # (batch, 32*7*7)
return self.fc(x) # raw logits, 10 classes
torch.manual_seed(42)
model = SmallCNN()
print(sum(p.numel() for p in model.parameters()), "parameters") # 21,258
Training loop¶
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(3):
model.train()
total_loss = 0.0
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device)
logits = model(xb)
loss = loss_fn(logits, yb)
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item() * xb.size(0)
print(f"epoch {epoch} avg loss {total_loss / len(train_ds):.4f}")
# epoch 0 avg loss 0.5123
# epoch 1 avg loss 0.3324
# epoch 2 avg loss 0.2887
model.eval()
correct = 0
with torch.no_grad():
for xb, yb in test_loader:
xb, yb = xb.to(device), yb.to(device)
correct += (model(xb).argmax(1) == yb).sum().item()
print(f"test accuracy: {correct / len(test_ds):.3f}") # ~0.90
Worked example: visualizing a filter's receptive field¶
sample_x, sample_y = test_ds[0]
with torch.no_grad():
feat_maps = torch.relu(model.conv1(sample_x.unsqueeze(0)))
print(feat_maps.shape) # torch.Size([1, 16, 28, 28]) -- 16 different edge/texture detectors
print(feat_maps[0, 0].mean().item(), feat_maps[0, 5].mean().item()) # different filters activate differently
Each of the 16 output channels of conv1 is a different learned filter,
each producing its own activation map over the image — one might respond to
vertical edges, another to horizontal ones, purely from data, never
hand-specified.
Cheat sheet¶
| Layer | Purpose |
|---|---|
nn.Conv2d(in_c, out_c, kernel_size, padding) |
Learn local spatial filters |
nn.MaxPool2d(k) |
Downsample, add translation tolerance |
.flatten(1) |
Turn feature maps into a vector before nn.Linear |
padding=1 with kernel_size=3 |
Keeps spatial size unchanged after the conv |
How It Actually Works¶
A convolution is a small weight matrix slid across the image, reusing the
same weights everywhere. Conv2d(1, 16, kernel_size=3) learns 16 separate
3×3 filters (plus a bias each). For a given filter, its output at
position (i, j) is Σ over the 3×3 neighborhood of the input centered
at (i, j), multiplying each of the 9 input pixels by the filter's
corresponding weight and summing — exactly a dot product between the filter
and that local patch. Sliding the same 9 weights across every position in
the image (rather than learning separate weights per pixel, as a fully
connected layer would) is what gives convolutions two properties MLPs lack:
drastically fewer parameters (9 weights detect an edge anywhere in the
image, not just in one location), and translation equivariance — shift
the input pattern and the output feature map shifts by the same amount,
because it's the literal same arithmetic operation applied at a different
offset.
padding=1 and stride mechanically determine output size, and pooling
mechanically halves it. With kernel_size=3 and padding=1 (one pixel of
zeros added around the border), a 28×28 input produces a 28×28 output
feature map — the padding exactly compensates for the 1-pixel shrinkage a
3×3 kernel would otherwise cause at each edge. MaxPool2d(2) then takes
non-overlapping 2×2 blocks and keeps only the maximum value in each,
mechanically producing a 14×14 output from a 28×28 input — a factor-of-4
reduction in the number of values, and a factor-of-2 reduction in each
spatial dimension. This is why the flattened size before fc is exactly
32 * 7 * 7: two MaxPool2d(2) layers take 28 → 14 → 7, and the second
conv produces 32 channels at that resolution — the fully-connected layer's
input size is a direct arithmetic consequence of the architecture above it,
not a free parameter.
Why stacking conv layers builds increasingly large receptive fields. A
single 3×3 filter in conv1 only ever looks at a 3-pixel-wide
neighborhood of the raw input — it cannot "see" a whole shoe or shirt shape.
But conv2's 3×3 filter operates on conv1's output feature map, where
each position already summarizes a 3-pixel neighborhood of the original
image; after the intervening pooling (which also aggregates a 2×2 block),
one unit in conv2's output is influenced by roughly a 8×8 patch of the
original 28×28 image. This growing receptive field with depth is the
literal mechanism by which CNNs build from local edge/texture detectors in
early layers to shape/part detectors in later layers — not a metaphor, but
a direct consequence of composing local, sliding-window operations.
Exercise¶
Add a third conv block (Conv2d(32, 64, 3, padding=1) + ReLU + a third
MaxPool2d(2)) to SmallCNN, updating the flattened size for fc
accordingly (work out the new spatial dimensions using the halving rule
above before running it). Train for 3 epochs and compare test accuracy and
parameter count against the two-block version. Report whether the deeper
network is worth the added parameters on this dataset.