{
"cells": [
{
"cell_type": "markdown",
"id": "686bd577",
"metadata": {},
"source": [
"# Lesson 34: Training a CNN\n",
"\n",
"Lesson 33's CNN was trained and evaluated on data drawn from the same, fairly generous distribution. Real training has a much sharper failure mode lurking: with too little data and too much model capacity, a network can perfectly memorize its training set while learning nothing that generalizes. This lesson makes that failure concrete, then fixes it two different ways: **data augmentation** (Lesson 8's transforms, repurposed) and **regularization** (weight decay)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9ffdfea8",
"metadata": {},
"outputs": [],
"source": [
"import numpy as np\n",
"import cv2\n",
"import torch\n",
"import torch.nn as nn\n",
"import torch.nn.functional as F\n",
"import matplotlib.pyplot as plt"
]
},
{
"cell_type": "markdown",
"id": "f0fc9f29",
"metadata": {},
"source": [
"## A deliberately hard, small dataset\n",
"\n",
"The same plus-vs-circle task as Lesson 33, but now with only **12 training images** and pixel noise added to every image, while the validation set stays large (150 images) so its accuracy is a reliable estimate."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "c42e5565",
"metadata": {},
"outputs": [],
"source": [
"def make_image(shape_type, cx, cy, size=16, rng=None):\n",
" img = np.zeros((size, size), dtype=np.float32)\n",
" if shape_type == 'plus':\n",
" img[cy - 1:cy + 2, cx - 3:cx + 4] = 1.0\n",
" img[cy - 3:cy + 4, cx - 1:cx + 2] = 1.0\n",
" else:\n",
" yy, xx = np.mgrid[0:size, 0:size]\n",
" img[((xx - cx) ** 2 + (yy - cy) ** 2) <= 9] = 1.0\n",
" if rng is not None:\n",
" img = np.clip(img + rng.normal(0, 0.4, img.shape), 0, 1).astype(np.float32)\n",
" return img\n",
"\n",
"def make_dataset(rng_local, n, position_range):\n",
" imgs, labels = [], []\n",
" for _ in range(n):\n",
" shape_type = rng_local.choice(['plus', 'circle'])\n",
" cx, cy = rng_local.integers(*position_range), rng_local.integers(*position_range)\n",
" imgs.append(make_image(shape_type, cx, cy, rng=rng_local))\n",
" labels.append(0.0 if shape_type == 'plus' else 1.0)\n",
" return np.array(imgs, dtype=np.float32), np.array(labels, dtype=np.float32)\n",
"\n",
"data_rng = np.random.default_rng(2)\n",
"X_train, y_train = make_dataset(data_rng, 12, (3, 13))\n",
"X_val, y_val = make_dataset(data_rng, 150, (3, 13))\n",
"\n",
"fig, axes = plt.subplots(1, 6, figsize=(11, 2))\n",
"for ax, im in zip(axes, X_train[:6]):\n",
" ax.imshow(im, cmap='gray')\n",
" ax.axis('off')\n",
"fig.suptitle('The entire training set is only 12 noisy images like these', y=1.05)\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "71b1752f",
"metadata": {},
"source": [
"## Watching it overfit\n",
"\n",
"Train a reasonably large CNN (Lesson 33's architecture) on just these 12 images, and track both training loss and *validation* loss (on the held-out 150 images) at every epoch."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "03268794",
"metadata": {},
"outputs": [],
"source": [
"class CNN(nn.Module):\n",
" def __init__(self):\n",
" super().__init__()\n",
" self.conv = nn.Sequential(\n",
" nn.Conv2d(1, 16, 5, padding=2), nn.ReLU(),\n",
" nn.MaxPool2d(2),\n",
" nn.Conv2d(16, 32, 5, padding=2), nn.ReLU(),\n",
" nn.AdaptiveMaxPool2d(1),\n",
" )\n",
" self.fc = nn.Linear(32, 1)\n",
"\n",
" def forward(self, x):\n",
" return self.fc(self.conv(x).flatten(1)).squeeze(-1)\n",
"\n",
"def train_tracked(model_cls, Xtr, ytr, Xval, yval, epochs=400, lr=0.01, weight_decay=0.0, seed=0):\n",
" torch.manual_seed(seed) # seed BEFORE constructing the model, so init is actually reproducible\n",
" model = model_cls()\n",
" opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)\n",
" train_losses, val_losses = [], []\n",
" for _ in range(epochs):\n",
" opt.zero_grad()\n",
" loss = F.binary_cross_entropy_with_logits(model(Xtr), ytr)\n",
" loss.backward()\n",
" opt.step()\n",
" with torch.no_grad():\n",
" train_losses.append(loss.item())\n",
" val_losses.append(F.binary_cross_entropy_with_logits(model(Xval), yval).item())\n",
" with torch.no_grad():\n",
" train_acc = ((model(Xtr) > 0).float() == ytr).float().mean().item()\n",
" val_acc = ((model(Xval) > 0).float() == yval).float().mean().item()\n",
" return train_losses, val_losses, train_acc, val_acc\n",
"\n",
"Xtr_t = torch.tensor(X_train).unsqueeze(1); ytr_t = torch.tensor(y_train)\n",
"Xval_t = torch.tensor(X_val).unsqueeze(1); yval_t = torch.tensor(y_val)\n",
"\n",
"train_losses, val_losses, train_acc, val_acc = train_tracked(CNN, Xtr_t, ytr_t, Xval_t, yval_t)\n",
"\n",
"print(f'final train accuracy: {train_acc:.1%}')\n",
"print(f'final val accuracy: {val_acc:.1%}')\n",
"print(f'val loss minimum was {min(val_losses):.3f} at epoch {np.argmin(val_losses)} '\n",
" f'(out of {len(val_losses)}); it ended at {val_losses[-1]:.3f}')\n",
"\n",
"plt.plot(train_losses, label='train loss')\n",
"plt.plot(val_losses, label='val loss')\n",
"plt.axvline(np.argmin(val_losses), color='gray', linestyle='--', linewidth=1, label='best val loss')\n",
"plt.xlabel('epoch'); plt.ylabel('loss'); plt.legend(fontsize=8)\n",
"plt.title('The classic overfitting curve')\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "88992670",
"metadata": {},
"source": [
"Training loss marches steadily to zero — the network perfectly memorizes all 12 images, noise included. Validation loss, meanwhile, bottoms out part-way through training and then climbs back up: past that point, every further epoch makes the model *more* confidently wrong about data it hasn't seen. Final validation accuracy lands well short of the training set's perfect score, despite the training set being fit exactly."
]
},
{
"cell_type": "markdown",
"id": "145842a7",
"metadata": {},
"source": [
"## Fix 1: data augmentation\n",
"\n",
"If there isn't enough real data, manufacture more from what's there. Apply random transformations from Lesson 8 (flips, small rotations) to each training image — the label doesn't change, but the pixels do, so the network sees a much wider variety of \"what a plus/circle can look like\" instead of memorizing 12 exact images."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "0df47d17",
"metadata": {},
"outputs": [],
"source": [
"def augment(img, rng_local):\n",
" if rng_local.random() < 0.5:\n",
" img = np.fliplr(img).copy()\n",
" if rng_local.random() < 0.5:\n",
" img = np.flipud(img).copy()\n",
" angle = rng_local.uniform(-10, 10)\n",
" M = cv2.getRotationMatrix2D((8, 8), angle, 1.0)\n",
" img = cv2.warpAffine(img, M, (16, 16))\n",
" return img.astype(np.float32)\n",
"\n",
"aug_rng = np.random.default_rng(5)\n",
"X_aug, y_aug = [], []\n",
"for _ in range(20): # 20 augmented copies of each of the 12 original images\n",
" for img, label in zip(X_train, y_train):\n",
" X_aug.append(augment(img, aug_rng))\n",
" y_aug.append(label)\n",
"X_aug, y_aug = np.array(X_aug, dtype=np.float32), np.array(y_aug, dtype=np.float32)\n",
"\n",
"fig, axes = plt.subplots(1, 6, figsize=(11, 2))\n",
"for ax, im in zip(axes, X_aug[:6]):\n",
" ax.imshow(im, cmap='gray')\n",
" ax.axis('off')\n",
"fig.suptitle(f'6 of {len(X_aug)} augmented copies, all still \"the same 12 base images\"', y=1.05)\n",
"plt.show()\n",
"\n",
"Xaug_t = torch.tensor(X_aug).unsqueeze(1); yaug_t = torch.tensor(y_aug)\n",
"_, _, aug_train_acc, aug_val_acc = train_tracked(CNN, Xaug_t, yaug_t, Xval_t, yval_t, epochs=150)\n",
"print(f'with augmentation: train acc = {aug_train_acc:.1%}, val acc = {aug_val_acc:.1%} (was {val_acc:.1%})')"
]
},
{
"cell_type": "markdown",
"id": "ee09c106",
"metadata": {},
"source": [
"## Fix 2: weight decay\n",
"\n",
"**Weight decay** adds a penalty proportional to the squared weight magnitudes directly into the loss (equivalently, it shrinks every weight slightly toward zero on every update). Large, highly-tuned weights are exactly what a network needs to memorize 12 specific noisy images; penalizing weight magnitude makes that memorization more costly relative to finding a simpler, smoother function — without adding a single extra training example."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7ab0f08c",
"metadata": {},
"outputs": [],
"source": [
"_, _, wd_train_acc, wd_val_acc = train_tracked(CNN, Xtr_t, ytr_t, Xval_t, yval_t, weight_decay=0.05)\n",
"print(f'with weight_decay=0.05: train acc = {wd_train_acc:.1%}, val acc = {wd_val_acc:.1%} (was {val_acc:.1%})')\n",
"\n",
"print()\n",
"print(f'{\"approach\":>20} {\"train acc\":>12} {\"val acc\":>12}')\n",
"print(f'{\"no fix\":>20} {train_acc:>12.1%} {val_acc:>12.1%}')\n",
"print(f'{\"augmentation\":>20} {aug_train_acc:>12.1%} {aug_val_acc:>12.1%}')\n",
"print(f'{\"weight decay\":>20} {wd_train_acc:>12.1%} {wd_val_acc:>12.1%}')"
]
},
{
"cell_type": "markdown",
"id": "4339cbfb",
"metadata": {},
"source": "Both fixes recover a large chunk of the lost validation accuracy, from two different angles: augmentation attacks the problem by giving the model more (synthetic) data to be right about; weight decay attacks it by making the model less willing to contort itself around a small dataset in the first place. In practice, both are normally used together, along with other regularizers like **dropout**, covered next, and **batch normalization** (Lesson 35), which incidentally also acts as a mild regularizer."
},
{
"cell_type": "markdown",
"id": "5b528267",
"source": "## Fix 3: dropout\n\n**Dropout** (Srivastava et al., 2014★) randomly zeroes out a fraction `p` of a layer's activations on every training step, forcing the surviving units to not rely on any one specific other unit always being present. To keep the layer's output at the same overall scale whether or not dropout is active, the surviving activations are rescaled by `1 / (1 - p)` — this is \"inverted dropout,\" what every framework's `Dropout` layer actually implements. At evaluation time, dropout does nothing at all: the full, unmodified layer runs, which is why `model.eval()` (used throughout this lesson already, for weight decay and augmentation too) matters — forgetting it would leave dropout randomly firing at test time.",
"metadata": {}
},
{
"cell_type": "code",
"id": "389bcfcf",
"source": "def manual_dropout(x, p, rng_gen):\n keep_prob = 1 - p\n mask = (torch.rand(x.shape, generator=rng_gen) < keep_prob).float()\n return x * mask / keep_prob # rescale so E[output] == input\n\nx_demo = torch.randn(2000, 10)\ntorch.manual_seed(0)\nout_torch = F.dropout(x_demo, p=0.3, training=True)\nout_manual = manual_dropout(x_demo, 0.3, torch.Generator().manual_seed(0))\n\nprint(f'fraction zeroed, torch: {(out_torch == 0).float().mean().item():.3f} (target p = 0.3)')\nprint(f'fraction zeroed, manual: {(out_manual == 0).float().mean().item():.3f}')\nprint(f'mean before dropout: {x_demo.mean().item():.4f}')\nprint(f'mean after dropout (torch): {out_torch.mean().item():.4f} (rescaling keeps this close to the input mean)')\nprint(f'mean after dropout (manual): {out_manual.mean().item():.4f}')\n\neval_mode_out = F.dropout(x_demo, p=0.3, training=False)\nprint(f'eval-mode dropout is a no-op: {torch.equal(eval_mode_out, x_demo)}')",
"metadata": {},
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"id": "d3333f4e",
"source": "Now apply it to this lesson's overfitting problem. Dropout needs *some* redundancy to work with — zeroing half of a 32-unit feature vector going straight into a 1-unit output leaves little room to help, so add a wider hidden layer (32 → 64 → 1) and place dropout on the 64-unit layer. With only 12 training images, results are noisy from one random seed to the next, so compare mean validation accuracy over several seeds rather than trusting a single run.",
"metadata": {}
},
{
"cell_type": "code",
"id": "9b2ed11c",
"source": "class CNNDropout(nn.Module):\n def __init__(self, dropout_p=0.0):\n super().__init__()\n self.conv = nn.Sequential(\n nn.Conv2d(1, 16, 5, padding=2), nn.ReLU(),\n nn.MaxPool2d(2),\n nn.Conv2d(16, 32, 5, padding=2), nn.ReLU(),\n nn.AdaptiveMaxPool2d(1),\n )\n self.fc1 = nn.Linear(32, 64)\n self.dropout = nn.Dropout(dropout_p)\n self.fc2 = nn.Linear(64, 1)\n\n def forward(self, x):\n feat = torch.relu(self.fc1(self.conv(x).flatten(1)))\n return self.fc2(self.dropout(feat)).squeeze(-1)\n\ndef val_acc_for(dropout_p, seed, epochs=150):\n torch.manual_seed(seed)\n model = CNNDropout(dropout_p)\n opt = torch.optim.Adam(model.parameters(), lr=0.01)\n for _ in range(epochs):\n model.train()\n opt.zero_grad()\n loss = F.binary_cross_entropy_with_logits(model(Xtr_t), ytr_t)\n loss.backward()\n opt.step()\n model.eval()\n with torch.no_grad():\n return ((model(Xval_t) > 0).float() == yval_t).float().mean().item()\n\nno_drop_accs = [val_acc_for(0.0, seed) for seed in range(8)]\ndrop_accs = [val_acc_for(0.5, seed) for seed in range(8)]\n\nprint(f'no dropout: mean val acc = {np.mean(no_drop_accs):.1%} (+/- {np.std(no_drop_accs):.1%}, 8 seeds)')\nprint(f'dropout(0.5): mean val acc = {np.mean(drop_accs):.1%} (+/- {np.std(drop_accs):.1%}, 8 seeds)')",
"metadata": {},
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"id": "8b4617a7",
"source": "Dropout gives a modest average improvement here, though the gap is small relative to the run-to-run noise from having only 12 training images — a much less dramatic effect than augmentation or weight decay showed above. That's realistic: dropout's benefit is well established at the scale of real datasets and real networks (hundreds of redundant units, thousands of examples), but in a toy setting this tiny, there just isn't much redundancy for it to exploit yet. In practice it is almost always combined with the other regularizers on this page, not used alone.",
"metadata": {}
},
{
"cell_type": "markdown",
"id": "e1262fcf",
"metadata": {},
"source": "### Exercise\n\n1. Try `weight_decay` values of `0.001`, `0.05`, and `1.0`. Is there a point where it starts to *hurt* training accuracy along with (eventually) validation accuracy? What does an excessively large weight decay do to the model's capacity to fit anything at all?\n2. Sweep `dropout_p` over `[0.0, 0.2, 0.4, 0.6, 0.8]` in `val_acc_for`, averaging over the same 8 seeds at each value. Is there a value that's clearly best, or does the mean stay within one standard deviation across most of the range given how little data there is?\n3. Increase the augmentation multiplier from 20 to 100 copies per base image. Does validation accuracy keep improving, or does it plateau — and if it plateaus, what does that suggest about the fundamental limit of augmenting a dataset that only contains 12 *underlying* examples to begin with?"
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.x"
}
},
"nbformat": 4,
"nbformat_minor": 5
}