{ "cells": [ { "cell_type": "markdown", "id": "686bd577", "metadata": {}, "source": [ "# Lesson 35: Training a CNN\n", "\n", "Lesson 34'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. Lesson 32 showed that more capacity means a model *can* represent more; this lesson shows the flip side — that same surplus capacity, with too little data to constrain it, is exactly what lets a network memorize instead of generalize. This lesson makes that failure concrete, then fixes it three different ways: **data augmentation** (Lesson 8's transforms, repurposed), **regularization** (weight decay), and **dropout**." ] }, { "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 34, 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 34'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 37), which incidentally also acts as a mild regularizer." ] }, { "cell_type": "markdown", "id": "5b528267", "metadata": {}, "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." ] }, { "cell_type": "code", "execution_count": null, "id": "389bcfcf", "metadata": {}, "outputs": [], "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", "\n", "x_demo = torch.randn(2000, 10)\n", "torch.manual_seed(0)\n", "out_torch = F.dropout(x_demo, p=0.3, training=True)\n", "out_manual = manual_dropout(x_demo, 0.3, torch.Generator().manual_seed(0))\n", "\n", "print(f'fraction zeroed, torch: {(out_torch == 0).float().mean().item():.3f} (target p = 0.3)')\n", "print(f'fraction zeroed, manual: {(out_manual == 0).float().mean().item():.3f}')\n", "print(f'mean before dropout: {x_demo.mean().item():.4f}')\n", "print(f'mean after dropout (torch): {out_torch.mean().item():.4f} (rescaling keeps this close to the input mean)')\n", "print(f'mean after dropout (manual): {out_manual.mean().item():.4f}')\n", "\n", "eval_mode_out = F.dropout(x_demo, p=0.3, training=False)\n", "print(f'eval-mode dropout is a no-op: {torch.equal(eval_mode_out, x_demo)}')" ] }, { "cell_type": "markdown", "id": "d3333f4e", "metadata": {}, "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." ] }, { "cell_type": "code", "execution_count": null, "id": "9b2ed11c", "metadata": {}, "outputs": [], "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", "\n", "def 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", "\n", "no_drop_accs = [val_acc_for(0.0, seed) for seed in range(8)]\n", "drop_accs = [val_acc_for(0.5, seed) for seed in range(8)]\n", "\n", "print(f'no dropout: mean val acc = {np.mean(no_drop_accs):.1%} (+/- {np.std(no_drop_accs):.1%}, 8 seeds)')\n", "print(f'dropout(0.5): mean val acc = {np.mean(drop_accs):.1%} (+/- {np.std(drop_accs):.1%}, 8 seeds)')" ] }, { "cell_type": "markdown", "id": "8b4617a7", "metadata": {}, "source": [ "In a toy setting this tiny (just 12 training images), there isn't much redundancy for dropout to exploit yet. Although only a modest improvement is shown here, dropout's benefit is well established at the scale of real datasets and real networks (hundreds of redundant units, thousands of examples). In practice dropout is almost always combined with the other regularizers on this page, not used alone." ] }, { "cell_type": "markdown", "id": "3bbe6c72", "metadata": {}, "source": [ "## Putting it together: real photos, real overfitting\n", "\n", "Every fix so far has been tested one at a time, on a synthetic 12-image toy problem chosen specifically to make the failure mode unmistakable. This closing section repeats the experiment on real **CIFAR-10** (Krizhevsky, 2009) photographs: a small, deliberately data-scarce training set of cats and trucks, with augmentation, weight decay, and dropout combined together. Real photographs contain lighting, clutter, and genuine visual ambiguity between classes.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "f4bc4094", "metadata": {}, "outputs": [], "source": [ "import pickle\n", "import tarfile\n", "import urllib.request\n", "from pathlib import Path\n", "\n", "CIFAR_URL = 'https://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz'\n", "CACHE_ROOT = Path.home() / '.cache' / 'cvintro'\n", "CACHE_DIR = CACHE_ROOT / 'cifar-10-batches-py'\n", "\n", "def ensure_cifar10():\n", " if CACHE_DIR.exists():\n", " return\n", " CACHE_ROOT.mkdir(parents=True, exist_ok=True)\n", " archive_path = CACHE_ROOT / 'cifar-10-python.tar.gz'\n", " if not archive_path.exists():\n", " print('Downloading CIFAR-10 (~163 MB, one-time, cached under ~/.cache/cvintro)...')\n", " urllib.request.urlretrieve(CIFAR_URL, archive_path)\n", " print('Extracting...')\n", " with tarfile.open(archive_path) as tar:\n", " tar.extractall(CACHE_ROOT)\n", "\n", "def load_cifar_batch(path):\n", " with open(path, 'rb') as f:\n", " d = pickle.load(f, encoding='bytes')\n", " imgs = d[b'data'].reshape(-1, 3, 32, 32).transpose(0, 2, 3, 1).astype(np.float32) / 255.0\n", " labels = np.array(d[b'labels'], dtype=np.int64)\n", " return imgs, labels\n", "\n", "CIFAR_LABELS = ['airplane', 'automobile', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck']\n", "\n", "ensure_cifar10()\n", "cifar_train_imgs, cifar_train_labels = [], []\n", "for i in range(1, 6):\n", " imgs, labels = load_cifar_batch(CACHE_DIR / f'data_batch_{i}')\n", " cifar_train_imgs.append(imgs)\n", " cifar_train_labels.append(labels)\n", "cifar_train_imgs = np.concatenate(cifar_train_imgs)\n", "cifar_train_labels = np.concatenate(cifar_train_labels)\n", "cifar_test_imgs, cifar_test_labels = load_cifar_batch(CACHE_DIR / 'test_batch')\n", "\n", "def take_class(imgs, labels, name, n, rng_local):\n", " idx = np.where(labels == CIFAR_LABELS.index(name))[0]\n", " idx = rng_local.permutation(idx)[:n]\n", " return imgs[idx].copy()\n", "\n", "cifar_rng = np.random.default_rng(4)\n", "N_CIFAR_TRAIN = 40 # per class -- deliberately small, real training data is expensive\n", "X_cifar_train = np.concatenate([\n", " take_class(cifar_train_imgs, cifar_train_labels, 'cat', N_CIFAR_TRAIN, cifar_rng),\n", " take_class(cifar_train_imgs, cifar_train_labels, 'truck', N_CIFAR_TRAIN, cifar_rng),\n", "])\n", "y_cifar_train = np.array([0.0] * N_CIFAR_TRAIN + [1.0] * N_CIFAR_TRAIN, dtype=np.float32)\n", "X_cifar_val = np.concatenate([\n", " take_class(cifar_test_imgs, cifar_test_labels, 'cat', 150, cifar_rng),\n", " take_class(cifar_test_imgs, cifar_test_labels, 'truck', 150, cifar_rng),\n", "])\n", "y_cifar_val = np.array([0.0] * 150 + [1.0] * 150, dtype=np.float32)\n", "\n", "fig, axes = plt.subplots(1, 6, figsize=(11, 2))\n", "for ax, im in zip(axes, X_cifar_train[:6]):\n", " ax.imshow(im)\n", " ax.axis('off')\n", "fig.suptitle(f'6 of {len(X_cifar_train)} real training photos (cat vs. truck)', y=1.05)\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "5f1a0d7d", "metadata": {}, "source": [ "Same recipe as before, adapted to 3-channel input: a plain CNN with no regularization, versus the same architecture with augmentation, weight decay, and dropout all applied together in a single run. Each configuration is averaged over 3 seeds, since 80 training images is still small enough for individual runs to vary." ] }, { "cell_type": "code", "execution_count": null, "id": "2696503c", "metadata": {}, "outputs": [], "source": [ "class CNNRGB(nn.Module):\n", " def __init__(self, dropout_p=0.0):\n", " super().__init__()\n", " self.conv = nn.Sequential(\n", " nn.Conv2d(3, 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", "\n", "def augment_photo(img, rng_local):\n", " # unlike `augment` above, no vertical flip: upside-down cats and trucks aren't realistic\n", " if rng_local.random() < 0.5:\n", " img = np.fliplr(img).copy()\n", " angle = rng_local.uniform(-10, 10)\n", " M = cv2.getRotationMatrix2D((16, 16), angle, 1.0)\n", " img = cv2.warpAffine(img, M, (32, 32))\n", " return img.astype(np.float32)\n", "\n", "Xcv_t = torch.tensor(X_cifar_val).permute(0, 3, 1, 2); ycv_t = torch.tensor(y_cifar_val)\n", "\n", "def train_cifar(use_aug, weight_decay, dropout_p, seed, epochs=300, lr=0.001):\n", " torch.manual_seed(seed)\n", " if use_aug:\n", " local_aug_rng = np.random.default_rng(seed + 100)\n", " Xa, ya = [], []\n", " for _ in range(10): # 10 augmented copies of each of the 80 base images\n", " for img, label in zip(X_cifar_train, y_cifar_train):\n", " Xa.append(augment_photo(img, local_aug_rng))\n", " ya.append(label)\n", " Xtr_np, ytr_np = np.array(Xa, dtype=np.float32), np.array(ya, dtype=np.float32)\n", " else:\n", " Xtr_np, ytr_np = X_cifar_train, y_cifar_train\n", " Xct_t = torch.tensor(Xtr_np).permute(0, 3, 1, 2); yct_t = torch.tensor(ytr_np)\n", " model = CNNRGB(dropout_p=dropout_p)\n", " opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)\n", " for _ in range(epochs):\n", " model.train()\n", " opt.zero_grad()\n", " loss = F.binary_cross_entropy_with_logits(model(Xct_t), yct_t)\n", " loss.backward()\n", " opt.step()\n", " model.eval()\n", " with torch.no_grad():\n", " train_acc = ((model(Xct_t) > 0).float() == yct_t).float().mean().item()\n", " val_acc = ((model(Xcv_t) > 0).float() == ycv_t).float().mean().item()\n", " return train_acc, val_acc\n", "\n", "cifar_configs = [\n", " ('no fix', dict(use_aug=False, weight_decay=0.0, dropout_p=0.0)),\n", " ('augmentation', dict(use_aug=True, weight_decay=0.0, dropout_p=0.0)),\n", " ('weight decay', dict(use_aug=False, weight_decay=0.01, dropout_p=0.0)),\n", " ('dropout', dict(use_aug=False, weight_decay=0.0, dropout_p=0.3)),\n", " ('all combined', dict(use_aug=True, weight_decay=0.01, dropout_p=0.3)),\n", "]\n", "\n", "print(f'{\"approach\":>15} {\"train acc\":>12} {\"val acc\":>12}')\n", "cifar_results = {}\n", "for name, cfg in cifar_configs:\n", " tr_accs, val_accs = [], []\n", " for seed in range(3):\n", " tr, va = train_cifar(seed=seed, **cfg)\n", " tr_accs.append(tr)\n", " val_accs.append(va)\n", " cifar_results[name] = (np.mean(tr_accs), np.mean(val_accs))\n", " print(f'{name:>15} {np.mean(tr_accs):>12.1%} {np.mean(val_accs):>12.1%}')" ] }, { "cell_type": "markdown", "id": "303a1cd4", "metadata": {}, "source": [ "No single fix is a clear winner here. Weight decay alone actually lands *below* the unregularized baseline — with `weight_decay=0.01`, training accuracy no longer even reaches 100%, meaning the penalty is strong enough to fight the objective without finding a better solution in its place. That's a useful, humbling result: the `weight_decay=0.05` value used earlier in this lesson was tuned for a much smaller network on 12 tiny synthetic images, and a regularization strength that works well in one setting doesn't automatically transfer to a different architecture or real photographic data. \n", "\n", "Augmentation and dropout each recover a modest amount of validation accuracy individually. \n", "\n", "\"All combined\" beats every single fix and the unregularized baseline, echoing the same lesson from the synthetic dataset: these three regularizers compound rather than substitute for each other. The gain over the unregularized baseline is real but modest (a few percentage points here), which is itself the honest takeaway: regularization narrows the gap between training and validation performance, it doesn't manufacture data or model capacity that isn't there. \n", "\n", "Closing that gap further, on real images, takes what later lessons introduce — more training data, better architectures (Lesson 37), and eventually **transfer learning** (Lesson 38): starting from a network already trained on millions of images, instead of memorizing from scratch on a handful of them." ] }, { "cell_type": "markdown", "id": "e1262fcf", "metadata": {}, "source": [ "### Exercises\n", "\n", "1. 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?\n", "2. 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?\n", "3. 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 }