Fall 2026
CSC-372 Project Part 7

Overfitting, Regularization and Generalization

Open In Colab

In this part of the project, you will focus on understanding and mitigating overfitting in deep learning models. Your goal is to demonstrate a significant improvement over a baseline and explain which methods contributed to it.

Apply each technique separately to a fresh model, keeping other settings fixed unless a change is required. Reuse the same training, validation, and test subsets throughout.

Use validation results to choose settings and stopping points. After those choices are finalized, evaluate the selected model on clean training and test images in evaluation mode.

For every technique, report:

Use ordinary cross-entropy on fine-class labels for evaluation, without label smoothing, regularization penalties, or auxiliary-task losses.

Compare both gaps with the baseline. Explain whether a smaller gap comes from improved test performance, reduced training performance, or both.


CIFAR-100 contains 60,000 color images, each 32 × 32 pixels, organized into 100 fine-grained classes and 20 broader categories.

\[\begin{aligned} \mathbf{x}_i \in & \mathbb{R}^{32 \times 32 \times 3} \\ \mathbf{X} \in & \mathbb{R}^{60,000 \times 3072} \\ \mathbf{y}_{fine} \in & \{1, \dots, 100\} \\ \mathbf{y}_{coarse} \in & \{1, \dots, 20\} \\ \end{aligned}\]

The dataset comes with predefined training and test splits of 50,000 and 10,000 images, respectively.

You are expected to randomly split the official training set into training and validation subsets of 40,000 and 10,000 images, respectively.

This train-validation split must be stratified, ensuring that each class is proportionally represented in both the training and validation subsets.

Your primary task is 100-class image classification. For multi-task learning, also predict each image’s 20-class coarse category.

Dataset and category information: CIFAR-100.

!pip install datasets
Collecting datasets
  Downloading datasets-4.5.0-py3-none-any.whl (515 kB)
     |████████████████████████████████| 515 kB 2.0 MB/s eta 0:00:01
Requirement already satisfied: tqdm>=4.66.3 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from datasets) (4.68.2)
Requirement already satisfied: numpy>=1.17 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from datasets) (1.23.5)
Collecting huggingface-hub<2.0,>=0.25.0
  Downloading huggingface_hub-1.8.0-py3-none-any.whl (625 kB)
     |████████████████████████████████| 625 kB 34.8 MB/s eta 0:00:01
Requirement already satisfied: pyyaml>=5.1 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from datasets) (6.0.2)
Collecting xxhash
  Downloading xxhash-4.0.2-cp39-cp39-macosx_10_9_x86_64.whl (38 kB)
Requirement already satisfied: filelock in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from datasets) (3.6.0)
Collecting fsspec[http]<=2025.10.0,>=2023.1.0
  Downloading fsspec-2025.10.0-py3-none-any.whl (200 kB)
     |████████████████████████████████| 200 kB 31.8 MB/s eta 0:00:01
Collecting dill<0.4.1,>=0.3.0
  Downloading dill-0.4.0-py3-none-any.whl (119 kB)
     |████████████████████████████████| 119 kB 29.6 MB/s eta 0:00:01
Requirement already satisfied: packaging in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from datasets) (24.1)
Requirement already satisfied: pandas in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from datasets) (1.4.2)
Collecting multiprocess<0.70.19
  Downloading multiprocess-0.70.18-py39-none-any.whl (133 kB)
     |████████████████████████████████| 133 kB 37.4 MB/s eta 0:00:01
Requirement already satisfied: requests>=2.32.2 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from datasets) (2.32.3)
Requirement already satisfied: httpx<1.0.0 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from datasets) (0.27.0)
Collecting pyarrow>=21.0.0
  Downloading pyarrow-21.0.0-cp39-cp39-macosx_12_0_x86_64.whl (32.7 MB)
     |████████████████████████████████| 32.7 MB 1.0 MB/s eta 0:00:011
Requirement already satisfied: aiohttp!=4.0.0a0,!=4.0.0a1 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (3.8.1)
Requirement already satisfied: async-timeout<5.0,>=4.0.0a3 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (4.0.1)
Requirement already satisfied: frozenlist>=1.1.1 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (1.2.0)
Requirement already satisfied: aiosignal>=1.1.2 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (1.2.0)
Requirement already satisfied: attrs>=17.3.0 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (24.2.0)
Requirement already satisfied: multidict<7.0,>=4.5 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (5.2.0)
Requirement already satisfied: yarl<2.0,>=1.0 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (1.6.3)
Collecting charset-normalizer<3.0,>=2.0
  Downloading charset_normalizer-2.1.1-py3-none-any.whl (39 kB)
Requirement already satisfied: typing-extensions>=3.6.5 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from async-timeout<5.0,>=4.0.0a3->aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (4.15.0)
Requirement already satisfied: certifi in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from httpx<1.0.0->datasets) (2024.7.4)
Requirement already satisfied: httpcore==1.* in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from httpx<1.0.0->datasets) (1.0.5)
Requirement already satisfied: anyio in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from httpx<1.0.0->datasets) (3.5.0)
Requirement already satisfied: idna in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from httpx<1.0.0->datasets) (3.8)
Requirement already satisfied: sniffio in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from httpx<1.0.0->datasets) (1.3.1)
Requirement already satisfied: h11<0.15,>=0.13 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from httpcore==1.*->httpx<1.0.0->datasets) (0.14.0)
Collecting hf-xet<2.0.0,>=1.4.2
  Downloading hf_xet-1.7.0-cp38-abi3-macosx_10_12_x86_64.whl (3.8 MB)
     |████████████████████████████████| 3.8 MB 67.2 MB/s eta 0:00:01
Collecting filelock
  Downloading filelock-3.19.1-py3-none-any.whl (15 kB)
Requirement already satisfied: typer in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from huggingface-hub<2.0,>=0.25.0->datasets) (0.7.0)
Requirement already satisfied: urllib3<3,>=1.21.1 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from requests>=2.32.2->datasets) (2.2.2)
Requirement already satisfied: python-dateutil>=2.8.1 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from pandas->datasets) (2.9.0.post0)
Requirement already satisfied: pytz>=2020.1 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from pandas->datasets) (2021.3)
Requirement already satisfied: six>=1.5 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from python-dateutil>=2.8.1->pandas->datasets) (1.16.0)
Requirement already satisfied: click<9.0.0,>=7.1.1 in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (from typer->huggingface-hub<2.0,>=0.25.0->datasets) (8.1.7)
Installing collected packages: charset-normalizer, hf-xet, fsspec, filelock, dill, xxhash, pyarrow, multiprocess, huggingface-hub, datasets
  Attempting uninstall: charset-normalizer
    Found existing installation: charset-normalizer 3.3.2
    Uninstalling charset-normalizer-3.3.2:
      Successfully uninstalled charset-normalizer-3.3.2
  Attempting uninstall: fsspec
    Found existing installation: fsspec 2022.2.0
    Uninstalling fsspec-2022.2.0:
      Successfully uninstalled fsspec-2022.2.0
  Attempting uninstall: filelock
    Found existing installation: filelock 3.6.0
    Uninstalling filelock-3.6.0:
      Successfully uninstalled filelock-3.6.0
ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
panel 0.13.0 requires bokeh<2.5.0,>=2.4.0, but you have bokeh 3.2.0 which is incompatible.
myst 1.0.4 requires google-auth<2.0.0,>=1.11.0, but you have google-auth 2.50.0 which is incompatible.
myst 1.0.4 requires google-auth-oauthlib<1.0.0,>=0.4.1, but you have google-auth-oauthlib 1.0.0 which is incompatible.
myst 1.0.4 requires urllib3<2.0.0,>=1.24.3, but you have urllib3 2.2.2 which is incompatible.
jupyterlab-server 2.10.3 requires jupyter-server~=1.4, but you have jupyter-server 2.14.2 which is incompatible.
google-api-core 1.25.1 requires google-auth<2.0dev,>=1.21.1, but you have google-auth 2.50.0 which is incompatible.
Successfully installed charset-normalizer-2.1.1 datasets-4.5.0 dill-0.4.0 filelock-3.19.1 fsspec-2025.10.0 hf-xet-1.7.0 huggingface-hub-1.8.0 multiprocess-0.70.18 pyarrow-21.0.0 xxhash-4.0.2
%pip install --upgrade Pillow
Requirement already satisfied: Pillow in /Users/fsultan/opt/anaconda3/lib/python3.9/site-packages (9.0.1)
Collecting Pillow
  Downloading pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl (5.3 MB)
     |████████████████████████████████| 5.3 MB 813 kB/s eta 0:00:01
Installing collected packages: Pillow
  Attempting uninstall: Pillow
    Found existing installation: Pillow 9.0.1
    Uninstalling Pillow-9.0.1:
      Successfully uninstalled Pillow-9.0.1
Successfully installed Pillow-11.3.0
Note: you may need to restart the kernel to use updated packages.
from datasets import load_dataset
ds = load_dataset("uoft-cs/cifar100")   # train / test splits

# fields: img, fine_label, coarse_label
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
ds
DatasetDict({
    train: Dataset({
        features: ['img', 'fine_label', 'coarse_label'],
        num_rows: 50000
    })
    test: Dataset({
        features: ['img', 'fine_label', 'coarse_label'],
        num_rows: 10000
    })
})
import numpy as np
from matplotlib import pyplot as plt

# Split complete rows so images and both labels stay aligned.
# Run this from the original ds, replacing your existing split code.
split = ds["train"].train_test_split(
    test_size=10_000,
    seed=42,
    stratify_by_column="fine_label"
)

train_ds = split["train"]
valid_ds = split["test"]
test_ds = ds["test"]

fine_names = train_ds.features["fine_label"]
coarse_names = train_ds.features["coarse_label"]

# Retrieve labels without loading every image.
y_train = np.array(train_ds["fine_label"])
coarse_train = np.array(train_ds["coarse_label"])

plt.style.use("dark_background")
fig, axes = plt.subplots(4, 5, figsize=(20, 14))

for label in range(20):
    i = np.flatnonzero(coarse_train == label)[0]
    example = train_ds[int(i)]
    ax = axes.flat[label]

    ax.imshow(example["img"])
    ax.set_title(
        f"$y_i$ = {example['fine_label']}\n"
        f"Fine: {fine_names.int2str(example['fine_label'])}\n"
        f"Coarse: {coarse_names.int2str(example['coarse_label'])}"
    )
    ax.axis("off")

plt.tight_layout()
plt.show()

print(
    f"Train: {len(train_ds)}, "
    f"Validation: {len(valid_ds)}, "
    f"Test: {len(test_ds)}"
)

Train: 40000, Validation: 10000, Test: 10000

0. Baseline

Build a sequential feed-forward neural network model and train it from random initialization using cross-entropy loss.

For the baseline:

  • Normalize the input images.
  • Use no data augmentation, dropout, label smoothing, injected noise, or weight decay.
  • Train for a fixed number of epochs.
  • Save training and validation loss and accuracy after every epoch.
  • Keep model size and training budget practical for repeated experiments.

Choose the optimizer, learning-rate schedule, batch size, and epoch budget before the main experiments. Keep them fixed unless a method requires a documented change.

Note that transforms.Compose combines image transformations into a sequence and applies them in the order listed whenever an image is loaded. In this notebook, it converts images into PyTorch tensors using ToTensor() and normalizes each color channel using Normalize(). For data augmentation, we add transformations such as random crops and horizontal flips before ToTensor(). These random transformations can produce a different version of an image each time it is loaded during training. For validation and testing, we use only conversion and normalization so that evaluation images are not randomly altered.

Report parameter count, training time, training accuracy, validation accuracy, and the generalization gap:

import numpy as np
import torch
import torch.nn as nn
from PIL import Image
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms

images = ds["train"]["img"]
labels = ds["train"]["fine_label"]

# Reuse the existing stratified split.
X_train = train_ds["img"]
y_train = train_ds["fine_label"]

X_valid = valid_ds["img"]
y_valid = valid_ds["fine_label"]

X_test = test_ds["img"]
y_test = test_ds["fine_label"]


class MyDataset(Dataset):
    def __init__(self, X, y):
        self.features = X
        self.labels = y
        self.transform = transforms.Compose([
            transforms.ToTensor(),
            transforms.Normalize(
                (0.5071, 0.4867, 0.4408),
                (0.2675, 0.2565, 0.2761)
            ),
        ])

    def __getitem__(self, index):
        image = self.features[index]

        # Handles PIL images or original uint8 image arrays.
        if not isinstance(image, Image.Image):
            image = Image.fromarray(np.asarray(image))

        image = self.transform(image.convert("RGB"))
        label = torch.tensor(int(self.labels[index]), dtype=torch.long)
        return image, label

    def __len__(self):
        return len(self.features)


train_dataset = MyDataset(X_train, y_train)
val_dataset = MyDataset(X_valid, y_valid)

train_loader = DataLoader(
    train_dataset, batch_size=64, shuffle=True
)
val_loader = DataLoader(
    val_dataset, batch_size=64, shuffle=False
)

if torch.cuda.is_available():
    device = torch.device("cuda")
elif torch.backends.mps.is_available():
    device = torch.device("mps")
else:
    device = torch.device("cpu")
model = nn.Sequential(
    nn.Flatten(),
    nn.Linear(32 * 32 * 3, 512),
    nn.ReLU(),
    nn.Linear(512, 256),
    nn.ReLU(),
    nn.Linear(256, 100),
).to(device)

optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()

num_epochs = 25

train_loss_history = []
val_loss_history = []

train_accuracy_history = []
val_accuracy_history = []

for epoch in range(num_epochs):
    model.train()
    train_loss_sum = 0.0
    train_correct = 0
    train_count = 0

    for inputs, targets in train_loader:
        inputs = inputs.to(device)
        targets = targets.to(device)

        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, targets)
        loss.backward()
        optimizer.step()

        batch_size = targets.size(0)
        train_loss_sum += loss.item() * batch_size
        train_correct += (outputs.argmax(dim=1) == targets).sum().item()
        train_count += batch_size

    model.eval()
    val_loss_sum = 0.0
    val_correct = 0
    val_count = 0

    with torch.no_grad():
        for inputs, targets in val_loader:
            inputs = inputs.to(device)
            targets = targets.to(device)

            outputs = model(inputs)
            loss = criterion(outputs, targets)

            batch_size = targets.size(0)
            val_loss_sum += loss.item() * batch_size
            val_correct += (outputs.argmax(dim=1) == targets).sum().item()
            val_count += batch_size

    train_loss_history.append(train_loss_sum / train_count)
    val_loss_history.append(val_loss_sum / val_count)

    print(
        f"Epoch {epoch + 1}/{num_epochs} | "
        f"Train loss: {train_loss_sum / train_count:.4f} | "
        f"Train accuracy: {train_correct / train_count:.2%} | "
        f"Val loss: {val_loss_sum / val_count:.4f} | "
        f"Val accuracy: {val_correct / val_count:.2%}"
    )

    train_accuracy_history.append(train_correct / train_count)
    val_accuracy_history.append(val_correct / val_count)
Epoch 1/25 | Train loss: 3.8296 | Train accuracy: 12.50% | Val loss: 3.6093 | Val accuracy: 16.26%
Epoch 2/25 | Train loss: 3.4446 | Train accuracy: 18.59% | Val loss: 3.4817 | Val accuracy: 18.95%
Epoch 3/25 | Train loss: 3.2802 | Train accuracy: 21.14% | Val loss: 3.4546 | Val accuracy: 19.09%
Epoch 4/25 | Train loss: 3.1357 | Train accuracy: 23.42% | Val loss: 3.4379 | Val accuracy: 19.79%
Epoch 5/25 | Train loss: 3.0219 | Train accuracy: 25.55% | Val loss: 3.4726 | Val accuracy: 19.80%
Epoch 6/25 | Train loss: 2.9071 | Train accuracy: 27.66% | Val loss: 3.4515 | Val accuracy: 20.02%
Epoch 7/25 | Train loss: 2.7843 | Train accuracy: 29.74% | Val loss: 3.5266 | Val accuracy: 20.32%
Epoch 8/25 | Train loss: 2.6722 | Train accuracy: 32.13% | Val loss: 3.6012 | Val accuracy: 20.64%
Epoch 9/25 | Train loss: 2.5626 | Train accuracy: 34.35% | Val loss: 3.6137 | Val accuracy: 21.87%
Epoch 10/25 | Train loss: 2.4460 | Train accuracy: 36.81% | Val loss: 3.7931 | Val accuracy: 20.92%
Epoch 11/25 | Train loss: 2.3348 | Train accuracy: 38.91% | Val loss: 3.8956 | Val accuracy: 21.27%
Epoch 12/25 | Train loss: 2.2307 | Train accuracy: 41.23% | Val loss: 3.9830 | Val accuracy: 21.43%
Epoch 13/25 | Train loss: 2.1361 | Train accuracy: 43.53% | Val loss: 4.1501 | Val accuracy: 20.71%
Epoch 14/25 | Train loss: 2.0385 | Train accuracy: 45.61% | Val loss: 4.2924 | Val accuracy: 20.60%
Epoch 15/25 | Train loss: 1.9448 | Train accuracy: 47.97% | Val loss: 4.4363 | Val accuracy: 20.74%
Epoch 16/25 | Train loss: 1.8715 | Train accuracy: 49.35% | Val loss: 4.5328 | Val accuracy: 20.90%
Epoch 17/25 | Train loss: 1.7906 | Train accuracy: 51.48% | Val loss: 4.7443 | Val accuracy: 20.27%
Epoch 18/25 | Train loss: 1.7293 | Train accuracy: 53.37% | Val loss: 4.8602 | Val accuracy: 20.32%
Epoch 19/25 | Train loss: 1.6617 | Train accuracy: 54.54% | Val loss: 5.0246 | Val accuracy: 20.48%
Epoch 20/25 | Train loss: 1.6015 | Train accuracy: 56.04% | Val loss: 5.2364 | Val accuracy: 20.06%
Epoch 21/25 | Train loss: 1.5528 | Train accuracy: 57.36% | Val loss: 5.4721 | Val accuracy: 20.40%
Epoch 22/25 | Train loss: 1.4843 | Train accuracy: 58.94% | Val loss: 5.5344 | Val accuracy: 20.25%
Epoch 23/25 | Train loss: 1.4496 | Train accuracy: 60.08% | Val loss: 5.7763 | Val accuracy: 19.83%
Epoch 24/25 | Train loss: 1.4026 | Train accuracy: 61.18% | Val loss: 6.1427 | Val accuracy: 20.14%
Epoch 25/25 | Train loss: 1.3704 | Train accuracy: 62.17% | Val loss: 6.1801 | Val accuracy: 19.80%
fig, axs = plt.subplots(2, 1, figsize=(8, 10))

axs[0].plot(train_loss_history, label="Train Loss", marker='s')
axs[0].plot(val_loss_history, label="Validation Loss", marker='o')
axs[0].set_xlabel("Epoch")
axs[0].set_ylabel("Loss")
axs[0].legend()
axs[0].grid(True)

axs[1].plot(train_accuracy_history, label="Train Accuracy", marker='s')
axs[1].plot(val_accuracy_history, label="Validation Accuracy", marker='o')
axs[1].set_xlabel("Epoch")
axs[1].set_ylabel("Accuracy")
axs[1].legend()
axs[1].grid(True)

plt.show()

Generalization gap = training accuracy − validation accuracy.

If the model does not overfit, explain what you observe. Do not deliberately weaken the baseline to make later improvements look larger.

fig, axs = plt.subplots(1, 2, figsize=(12, 5))

axs[0].fill_between(range(len(train_loss_history)), val_loss_history, train_loss_history, alpha=0.3, label='Loss Generalization Gap', hatch='/')
axs[0].legend()
axs[0].set_xlabel('Epochs')
axs[0].set_ylabel('Loss')
axs[0].set_title('Training and Validation Loss')

axs[1].fill_between(range(len(train_accuracy_history)), train_accuracy_history, val_accuracy_history, alpha=0.3, label='Accuracy Generalization Gap', hatch='/')
axs[1].legend()
axs[1].set_xlabel('Epochs')
axs[1].set_ylabel('Accuracy')
axs[1].set_title('Training and Validation Accuracy Curves')

plt.show()

X_test
Column([<PIL.PngImagePlugin.PngImageFile image mode=RGB size=32x32 at 0x7F824052B520>, <PIL.PngImagePlugin.PngImageFile image mode=RGB size=32x32 at 0x7F824052BE80>, <PIL.PngImagePlugin.PngImageFile image mode=RGB size=32x32 at 0x7F824052B0A0>, <PIL.PngImagePlugin.PngImageFile image mode=RGB size=32x32 at 0x7F824052BC40>, <PIL.PngImagePlugin.PngImageFile image mode=RGB size=32x32 at 0x7F824052B790>, ...])
from sklearn.metrics import classification_report
from torch.utils.data import DataLoader
import torch

test_dataset = MyDataset(X_test, y_test)
test_loader = DataLoader(
    test_dataset, batch_size=64, shuffle=False
)

model.eval()
y_true = []
y_pred = []

with torch.no_grad():
    for inputs, targets in test_loader:
        outputs = model(inputs.to(device))
        predictions = outputs.argmax(dim=1)

        y_true.extend(targets.cpu().tolist())
        y_pred.extend(predictions.cpu().tolist())

class_names = ds["train"].features["fine_label"].names

print(classification_report(
    y_true,
    y_pred,
    labels=list(range(100)),
    target_names=class_names,
    digits=3,
    zero_division=0,
))
               precision    recall  f1-score   support

        apple      0.433     0.550     0.485       100
aquarium_fish      0.336     0.380     0.357       100
         baby      0.119     0.190     0.147       100
         bear      0.131     0.140     0.135       100
       beaver      0.220     0.090     0.128       100
          bed      0.220     0.280     0.247       100
          bee      0.142     0.190     0.162       100
       beetle      0.258     0.310     0.282       100
      bicycle      0.229     0.190     0.208       100
       bottle      0.315     0.390     0.348       100
         bowl      0.167     0.070     0.099       100
          boy      0.221     0.170     0.192       100
       bridge      0.209     0.270     0.236       100
          bus      0.383     0.180     0.245       100
    butterfly      0.198     0.170     0.183       100
        camel      0.171     0.130     0.148       100
          can      0.377     0.200     0.261       100
       castle      0.345     0.410     0.374       100
  caterpillar      0.191     0.250     0.216       100
       cattle      0.157     0.140     0.148       100
        chair      0.568     0.460     0.508       100
   chimpanzee      0.322     0.460     0.379       100
        clock      0.301     0.250     0.273       100
        cloud      0.285     0.570     0.380       100
    cockroach      0.386     0.510     0.440       100
        couch      0.132     0.100     0.114       100
          cra      0.203     0.120     0.151       100
    crocodile      0.151     0.080     0.105       100
          cup      0.412     0.490     0.447       100
     dinosaur      0.381     0.160     0.225       100
      dolphin      0.315     0.400     0.352       100
     elephant      0.213     0.160     0.183       100
     flatfish      0.265     0.180     0.214       100
       forest      0.161     0.460     0.238       100
          fox      0.223     0.210     0.216       100
         girl      0.197     0.120     0.149       100
      hamster      0.276     0.240     0.257       100
        house      0.175     0.210     0.191       100
     kangaroo      0.156     0.120     0.136       100
     keyboard      0.229     0.300     0.260       100
         lamp      0.300     0.240     0.267       100
   lawn_mower      0.588     0.470     0.522       100
      leopard      0.162     0.120     0.138       100
         lion      0.237     0.180     0.205       100
       lizard      0.079     0.110     0.092       100
      lobster      0.232     0.130     0.167       100
          man      0.231     0.150     0.182       100
   maple_tree      0.400     0.320     0.356       100
   motorcycle      0.273     0.390     0.321       100
     mountain      0.250     0.170     0.202       100
        mouse      0.154     0.040     0.063       100
     mushroom      0.206     0.340     0.257       100
     oak_tree      0.475     0.570     0.518       100
       orange      0.410     0.570     0.477       100
       orchid      0.425     0.310     0.358       100
        otter      0.065     0.040     0.049       100
    palm_tree      0.357     0.300     0.326       100
         pear      0.223     0.230     0.227       100
 pickup_truck      0.312     0.200     0.244       100
    pine_tree      0.165     0.160     0.162       100
        plain      0.531     0.690     0.600       100
        plate      0.467     0.430     0.448       100
        poppy      0.355     0.440     0.393       100
    porcupine      0.417     0.250     0.312       100
       possum      0.119     0.150     0.133       100
       rabbit      0.095     0.120     0.106       100
      raccoon      0.135     0.050     0.073       100
          ray      0.260     0.400     0.315       100
         road      0.489     0.640     0.554       100
       rocket      0.430     0.370     0.398       100
         rose      0.297     0.270     0.283       100
          sea      0.595     0.250     0.352       100
         seal      0.108     0.100     0.104       100
        shark      0.200     0.280     0.233       100
        shrew      0.126     0.110     0.118       100
        skunk      0.266     0.510     0.349       100
   skyscraper      0.451     0.510     0.479       100
        snail      0.095     0.110     0.102       100
        snake      0.066     0.080     0.072       100
       spider      0.163     0.070     0.098       100
     squirrel      0.110     0.080     0.092       100
    streetcar      0.183     0.110     0.138       100
    sunflower      0.573     0.510     0.540       100
 sweet_pepper      0.247     0.190     0.215       100
        table      0.122     0.180     0.146       100
         tank      0.359     0.420     0.387       100
    telephone      0.304     0.350     0.326       100
   television      0.163     0.290     0.209       100
        tiger      0.143     0.110     0.124       100
      tractor      0.227     0.170     0.194       100
        train      0.205     0.250     0.225       100
        trout      0.367     0.400     0.383       100
        tulip      0.171     0.200     0.184       100
       turtle      0.162     0.120     0.138       100
     wardrobe      0.472     0.510     0.490       100
        whale      0.417     0.350     0.380       100
  willow_tree      0.222     0.320     0.262       100
         wolf      0.195     0.160     0.176       100
        woman      0.152     0.150     0.151       100
         worm      0.333     0.190     0.242       100

     accuracy                          0.262     10000
    macro avg      0.265     0.262     0.255     10000
 weighted avg      0.265     0.262     0.255     10000

1. Weight-penalty regularization

Add an L2 penalty to the loss, then compare it with decoupled weight decay.

\[ L = L_{CE} + \lambda \sum_i w_i^2 \]

  • Sweep λ ∈ {1e-5, 1e-4, 5e-4, 1e-3, 5e-3}, implemented as an explicit penalty with Adam.
  • Repeat the best λ using AdamW(weight_decay=...) and compare. Explain why L2-in-the-loss and decoupled decay are not equivalent under Adam.
  • Optional: L1 penalty; report the fraction of near-zero weights (|w| < 1e-3).

Explicit regularization

Add L2 regularization or decoupled weight decay. Explain which you used.

Does discouraging large weights reduce overfitting?

model = nn.Sequential(
    nn.Flatten(),
    nn.Linear(32 * 32 * 3, 512),
    nn.ReLU(),
    nn.Linear(512, 256),
    nn.ReLU(),
    nn.Linear(256, 100),
).to(device)

optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
criterion = nn.CrossEntropyLoss()

num_epochs = 25

train_with_reg_loss_history = []
val_with_reg_loss_history = []

train_with_reg_accuracy_history = []
val_with_reg_accuracy_history = []

# Implement training loop with L2 regularization (weight decay)
# Plot with regularization and compare with without regularization

fig, axs = plt.subplots(1, 2, figsize=(12, 5))
axs[0].fill_between(range(len(train_with_reg_loss_history)), val_with_reg_loss_history, train_with_reg_loss_history, alpha=0.3, label='Loss Generalization Gap')
axs[0].fill_between(range(len(train_loss_history)), val_loss_history, train_loss_history, alpha=0.3, label='Loss Generalization Gap Without Regularization')
axs[0].legend()
axs[0].set_xlabel('Epochs')
axs[0].set_ylabel('Loss')
axs[0].set_title('Training and Validation Loss')

axs[1].fill_between(range(len(train_with_reg_accuracy_history)), train_with_reg_accuracy_history, val_with_reg_accuracy_history, alpha=0.3, label='Accuracy Generalization Gap With Regularization', hatch='-')
axs[1].fill_between(range(len(train_accuracy_history)), val_accuracy_history, train_accuracy_history, alpha=0.3, label='Accuracy Generalization Gap Without Regularization', hatch='|')
axs[1].legend()
axs[1].set_xlabel('Epochs')
axs[1].set_ylabel('Accuracy')
axs[1].set_title('Training and Validation Accuracy Curves')

plt.show()

2. Early stopping

Monitor validation loss each epoch and stop when it hasn’t improved for patience epochs, restoring the best weights.

Consider patience \(\in \{3, 5, 10, 20\}\).

Report the stopping epoch, the restored epoch, and the compute saved relative to 60 epochs.

Can you improve generalization while reducing training time? Does stopping on validation loss vs. validation accuracy pick different epochs?


3. Noise on inputs

During training only, add zero-mean Gaussian noise with standard deviation σ to each normalized input batch: x + σ·randn_like(x). Here, randn_like(x) generates a tensor of the same shape as x with elements drawn from a standard normal distribution (mean 0, variance 1).

Consider \(\sigma \in \{0.01, 0.05, 0.1, 0.2, 0.4\}\)

Connect your result to the known equivalence between small input noise and a Tikhonov (L2-type) penalty on the input gradient.

Does training on perturbed inputs improve robustness or clean accuracy?

4. Label smoothing

Train the baseline model using nn.CrossEntropyLoss(label_smoothing=p) Consider different values of p such as 0.05, 0.1, 0.2, and 0.3.

Compare its training–test loss and accuracy gaps with the baseline from 0.

For evaluation, use ordinary cross-entropy without label smoothing.

Does label smoothing reduce the generalization gap?


5. Data augmentation

Train a model using random crops (RandomCrop(32, padding=4)) and horizontal flips (RandomHorizontalFlip()) on training images only.

Compare its training–test loss and accuracy gaps with the baseline. For evaluation, use clean, unaugmented images for both datasets.

Does data augmentation reduce the generalization gap?

train_transform = transforms.Compose([
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.ToTensor(),
    transforms.Normalize(
        (0.5071, 0.4867, 0.4408),
        (0.2675, 0.2565, 0.2761),
    ),
])

6. Multi-task learning

Modify the baseline model to share its hidden layers between two output layers: one predicting 100 fine classes and another predicting 20 coarse categories.

Train using the sum of the two cross-entropy losses:

\[L = L_{\text{fine}} + L_{\text{coarse}}.\]

Compare its training–test loss and accuracy gaps with the baseline, using only fine-class predictions and fine-class cross-entropy for evaluation.

Does learning both tasks reduce the generalization gap?



7. Transfer learning

Use a ResNet-18 pretrained on ImageNet. Freeze its existing layers and replace its final classification layer with a new layer that predicts the 100 CIFAR-100 classes. Train only this new layer.

Compare its training–test loss and accuracy gaps with the baseline.

Because this experiment changes both the architecture and the initial weights, differences from the baseline cannot be attributed to pretraining alone.

Does the pretrained model reduce the generalization gap? Does it also change training and test performance?

Step 1: Load the pretrained model

The pretrained model has already learned image features from ImageNet. We will reuse those features and train a new final classifier for CIFAR-100.

import time
import torch
import torch.nn as nn

from torch.utils.data import Dataset, DataLoader
from torchvision.models import resnet18, ResNet18_Weights

weights = ResNet18_Weights.IMAGENET1K_V1
transfer_model = resnet18(weights=weights)

print(transfer_model.fc)

model = transfer_model
model.eval()

image = ds["test"][0]["img"].convert("RGB")  # One CIFAR-100 image
x = weights.transforms()(image)             # Resize, crop, normalize
x = x.unsqueeze(0)                          # Add batch dimension

with torch.no_grad():
    scores = model(x)

predicted_class = scores.argmax(dim=1).item()

print("Input shape:", x.shape)       # [1, 3, 224, 224]
print("Output shape:", scores.shape) # [1, 1000]
print("Prediction:", weights.meta["categories"][predicted_class])

The original final layer predicts 1,000 ImageNet classes. We need 100 outputs instead.

Step 2: Freeze the existing layers and replace the classifier**

requires_grad = False prevents the optimizer from updating the pretrained parameters. The new layer has trainable parameters by default.

# Freeze all existing parameters.
for parameter in transfer_model.parameters():
    parameter.requires_grad = False

# Replace the original classifier with a trainable CIFAR-100 classifier.
num_features = transfer_model.fc.in_features
transfer_model.fc = nn.Linear(num_features, 100)

transfer_model = transfer_model.to(device)

print("New classifier:", transfer_model.fc)
print(
    "Trainable parameters:",
    sum(p.numel() for p in transfer_model.parameters()
        if p.requires_grad)
)
The order matters: freeze the existing model before creating the new layer.

### Step 3: Prepare images for the pretrained model

Use the preprocessing supplied with the pretrained weights. It resizes and center-crops images to 224 × 224, converts them into tensors, and applies ImageNet normalization.
Use the same preprocessing for training, validation, and testing in this experiment.
transfer_transform = weights.transforms()


class TransferDataset(Dataset):
    def __init__(self, rows):
        self.rows = rows

    def __len__(self):
        return len(self.rows)

    def __getitem__(self, index):
        example = self.rows[int(index)]

        image = example["img"].convert("RGB")
        image = transfer_transform(image)

        label = int(example["fine_label"])
        return image, label


# Reuse the existing stratified split. Do not split the data again.
transfer_train_dataset = TransferDataset(train_ds)
transfer_valid_dataset = TransferDataset(valid_ds)
transfer_test_dataset = TransferDataset(test_ds)

transfer_train_loader = DataLoader(
    transfer_train_dataset, batch_size=64, shuffle=True
)

transfer_valid_loader = DataLoader(
    transfer_valid_dataset, batch_size=64, shuffle=False
)

transfer_test_loader = DataLoader(
    transfer_test_dataset, batch_size=64, shuffle=False
)

If memory is limited, reduce the batch size and document the change.

Step 4: Define the loss, optimizer, and evaluation function

The model returns raw class scores, so use cross-entropy directly. Only the new classifier is passed to the optimizer.

optimizer = torch.optim.Adam(
    transfer_model.fc.parameters(),
    lr=1e-3
)

criterion = nn.CrossEntropyLoss()


def evaluate_transfer(model, loader):
    model.eval()

    loss_sum = 0.0
    correct = 0
    count = 0

    with torch.no_grad():
        for images, labels in loader:
            images = images.to(device)
            labels = labels.to(device)

            logits = model(images)
            loss = criterion(logits, labels)

            count += labels.size(0)
            loss_sum += loss.item() * labels.size(0)
            correct += (logits.argmax(dim=1) == labels).sum().item()

    return loss_sum / count, correct / count

Step 5: Train the new classifier

Keep the frozen model in evaluation mode while training the classifier. ResNet contains batch-normalization layers whose stored statistics would otherwise change during training.

eval() does not disable gradients: the new classifier can still learn

Input shape: torch.Size([1, 3, 224, 224])
Output shape: torch.Size([1, 1000])
Prediction: volcano

Note that the imported ResNet-18 model has a fully connected layer model.fc at the end, which we replace with a new nn.Linear layer to match the number of classes in our dataset:

model.fc.in_features
512
model.layer1[0].conv1.in_channels
64

8. Ensembling

Train two baseline neural networks that differ only in their random seeds. Form an ensemble by averaging their predicted class probabilities (softmax outputs), then select the class with the highest average probability. Do not average predicted class IDs.

Evaluate all models on the same held-out test set.

Report the ensemble’s total training time, inference time, and model storage requirements relative to a single baseline model, using the same hardware and inference batch size.

Does the ensemble’s improvement in generalization justify its additional inference time and storage requirements?

9. Dropout

Add dropout (torch.nn.Dropout) before each fully connected layer.

Consider \(p \in \{0.1, 0.25, 0.5, 0.7\}\) as the dropout probability for fully connected layers.

Note that you should call model.eval() at evaluation time, which disables dropout and ensures deterministic behavior. Dropout is only active during training when model.train() is set.

Add dropout at documented locations and tune its probability.

How does dropout placement or strength affect learning?



10. Noise on weights

Train the baseline model with temporary Gaussian noise added to its weights before each forward pass:

\[ \tilde{w} = w + 0.05\,|w|\,z, \qquad z \sim \mathcal{N}(0,1). \]

Sample fresh noise for each training batch. After loss.backward(), restore the original weights before calling optimizer.step().

Evaluate using clean weights. Compare the training–test loss and accuracy gaps with the baseline.

Add noise using torch.randn_like(x). Randn_like generates a tensor of the same shape as x with elements drawn from a standard normal distribution i.e. with mean 0 and variance 1.

For example, for a weight tensor w, you can add noise as

z = torch.randn_like(w)
w_noisy = w + 0.05 * w.abs() * z

Does weight noise reduce the generalization gap?