Assignment 1 · Neural network fundamentals¶
Train an MNIST digit classifier using PyTorch.
| Part | Main work | Points |
|---|---|---|
| 1. Images to predictions | Data loading, shapes, scores and stable loss | 12 |
| 2. Hidden layers and gradients | ReLU, manual backpropagation, autograd and modules | 20 |
| 3. Optimization and training | Momentum, AdamW, training and evaluation | 22 |
| 4. Initialization and normalization | Initialization, diagnosis and BatchNorm training | 14 |
| 5. Generalization | Dropout, controlled comparisons and model selection | 12 |
| Problem set | Four calculation/short-answer questions in a separate PDF | 20 |
Run code cells in order with Shift+Enter and complete the TODOs without changing function names or arguments. Write short answers in Markdown cells (double-click to edit; Shift+Enter to render).
Submit this notebook with outputs, a1_selected_model.pt, and your written answers as A1_Written_Answers.pdf. The problems are in A1_Written_Questions.pdf.
0. Set up your PyTorch workspace¶
Open START_HERE.html in your browser for the setup and notebook instructions. Use JupyterLab or VS Code on your own computer; no GPU is needed.
The local setup guide walks through installing Python, opening a terminal, creating an environment and installing packages. Follow the official PyTorch installation page when installing PyTorch and torchvision. Keep the notebook, helpers and data/ together; MNIST is included.
Select the DDA4220 A1 (CPU) kernel in JupyterLab, or the assignment's .venv environment in VS Code. The kernel is the Python process that runs the notebook's code.
Run the first cell to check the selected Python environment and installed versions.
import sys
import torch
import torchvision
print("Python executable:", sys.executable)
print("PyTorch version:", torch.__version__)
print("Torchvision version:", torchvision.__version__)
For ModuleNotFoundError, check the selected kernel. Missing helpers or data usually mean the full student folder was not extracted. Restart the kernel after installing packages.
Run the next setup cell after each restart. Calls beginning with support. are supplied utilities; the assessed implementations stay in this notebook.
import copy
import math
import matplotlib.pyplot as plt
import numpy as np
import torch
from torch import nn
from torch.nn import functional as F
from torch.utils.data import Dataset, DataLoader, Subset
from torchvision import datasets, transforms
import a1_v8_support as support
from a1_v8_checks import check, check_indexing_practice, check_broadcasting_practice
support.seed_all(seed=0)
device = torch.device("cpu")
results = {}
Tensor basics¶
What is a tensor?¶
A tensor is an array of numbers with any number of dimensions: a scalar has none, a vector has one, and a matrix has two. PyTorch stores data and model parameters as tensors.
torch.tensor(...) creates a tensor from Python values. Its shape gives the size of each axis, dtype gives the number type, and device tells you whether it is stored on a CPU or GPU. We use floating-point numbers for image values and class scores, and integers (torch.long) for class labels.
Run the cell to inspect a matrix of scores: two examples, each with three class scores.
toy_scores = torch.tensor([[2.0, 1.0, 0.0], [0.0, 3.0, 1.0]], dtype=torch.float32)
toy_labels = torch.tensor([0, 2], dtype=torch.long)
class_bias = torch.tensor([0.1, 0.2, 0.3])
print(toy_scores)
print("Dimensions:", toy_scores.ndim, "| shape:", tuple(toy_scores.shape))
print("Number type:", toy_scores.dtype, "| stored on:", toy_scores.device)
Practice: indexing¶
Indices start at zero. Each row is an example; each column is a class score.
x[0]selects a row and removes that axis;x[:1]keeps it.x[:, 1:]keeps all rows and selects columns from index 1 onward.x[mask]selects rows where a Boolean (true/false) mask is true;labels == 0creates such a mask.x[rows, columns]pairs the row and column indices to select one entry per pair.
Complete the four expressions below for scores of shape (B,C) and labels of shape (B,), with at least two classes. Here B is the number of examples, C the number of classes, and K the number of labels equal to zero. torch.arange(B) gives row indices; negative slice indices count from the end.
Replace each None with a tensor expression and remove the NotImplementedError. Preserve the input tensors.
def indexing_practice(scores, labels):
first_row = None # TODO: first example, keeping shape (1,C).
last_two_columns = None # TODO: last two class columns, shape (B,2).
class_zero_rows = None # TODO: examples whose label is 0, shape (K,C).
target_scores = None # TODO: each example's correct-class score, shape (B,).
raise NotImplementedError("Complete the four expressions, then remove this line.")
return first_row, last_two_columns, class_zero_rows, target_scores
Check your selections on several shapes, then inspect the results for the two examples above.
check_indexing_practice(indexing_practice)
first_row, last_columns, zero_rows, target_scores = indexing_practice(
toy_scores, toy_labels
)
print("First example with batch axis:\n", first_row)
print("Last two columns:\n", last_columns)
print("Examples labeled 0:\n", zero_rows)
print("Correct-class scores:", target_scores)
Practice: broadcasting¶
Broadcasting matches dimensions from the right: each pair must be equal, or one must be 1. Missing leading dimensions act as size 1.
For (2,3) scores, a (3,) bias is shared across rows. A (2,1) value is shared across columns within each row. Reductions such as max combine values along the chosen dim; keepdim=True retains that axis with size 1. .values selects the maxima from the result.
Complete two operations: add a (C,) bias to every row, and subtract each row's maximum from the original scores. Both results must have shape (B,C). Use dim=1 for the row maxima and preserve the inputs. Without keepdim, the (2,) maxima would not broadcast against (2,3) scores.
def broadcasting_practice(scores, bias):
biased_scores = None # TODO: add the class biases, shape (B,C).
row_maximum = None # TODO: one maximum per row, shape (B,1).
shifted_scores = None # TODO: subtract from the original scores, shape (B,C).
raise NotImplementedError("Complete the three expressions, then remove this line.")
return biased_scores, shifted_scores
Check both operations, then compare the resulting values and shapes.
check_broadcasting_practice(broadcasting_practice)
biased_scores, shifted_scores = broadcasting_practice(toy_scores, class_bias)
print("With class biases:\n", biased_scores, "| shape:", tuple(biased_scores.shape))
print(
"After subtracting row maxima:\n",
shifted_scores,
"| shape:",
tuple(shifted_scores.shape),
)
1. From images to predictions · 12 points¶
1.1 Load images and build batches · 4 points¶
MNIST contains grayscale images of handwritten digits. Each example is an image paired with its label: the correct digit, from 0 to 9. We will train a model to predict that label from the pixels.
A Dataset gives us individual examples. PyTorch's torch.utils.data.Dataset defines this interface: len(dataset) gives the number of examples, and dataset[i] retrieves example i. For MNIST, this returns (image, label). torchvision.datasets.MNIST is a ready-made Dataset that reads the MNIST files.
Create two MNIST datasets inside build_datasets:
datasets.MNIST(root=..., train=True, transform=..., download=False)reads the official training pool;train=Falsereads the test pool.rootis the supplied data folder.- Pass
transforms.ToTensor()as the transform. It converts each retrieved image from integer pixel values 0–255 to a floating-point tensor of shape(1,28,28)with values in [0,1]. The first axis is the single grayscale channel; do not divide by 255 again. Subset(dataset, indices)is another Dataset containing only the listed examples. Return three Subsets usingsplit['train'],split['validation']andsplit['test'].
Training examples change the model's weights. Validation examples help choose the model; test examples measure its final performance. Use the supplied indices to select 10,000 training and 2,000 validation examples from the training pool. Keep the separate test pool for Part 5.
def build_datasets(root, split):
transform = None # TODO: create the image-to-tensor transform.
mnist_train = None # TODO: create the official training Dataset.
mnist_test = None # TODO: create the official test Dataset.
train_dataset = None # TODO: Subset of mnist_train.
validation_dataset = None # TODO: another Subset of mnist_train.
test_dataset = None # TODO: Subset of mnist_test.
raise NotImplementedError("Create the datasets, then remove this line.")
return train_dataset, validation_dataset, test_dataset
Read the supplied split indices, then create your datasets. Check their sizes and confirm that the training subset is a PyTorch Dataset.
with np.load(support.DATA / "mnist_splits.npz") as split_file:
split = {key: split_file[key].copy() for key in split_file.files}
train_dataset, validation_dataset, test_dataset = build_datasets(support.DATA, split)
print("Is a PyTorch Dataset:", isinstance(train_dataset, Dataset))
print("Training examples:", len(train_dataset))
print("Validation examples:", len(validation_dataset))
print("Reserved test examples:", len(test_dataset))
Retrieve one (image, label) pair with train_dataset[0]. The Subset's .dataset points to its underlying MNIST Dataset; .data holds the raw pixels. Compare shapes, dtypes and pixel ranges before and after the transform. .item() reads a one-element tensor as a Python number.
original_index = int(split["train"][0])
raw_pixels = train_dataset.dataset.data[original_index]
image, label = train_dataset[0]
print("Stored pixel shape:", tuple(raw_pixels.shape))
print("Stored pixel dtype:", raw_pixels.dtype)
print("Stored pixel range:", raw_pixels.min().item(), raw_pixels.max().item())
print("Transformed image shape:", tuple(image.shape))
print("Transformed image dtype:", image.dtype)
print("Transformed pixel range:", image.min().item(), image.max().item())
print("Single class label:", label, "| Python type:", type(label).__name__)
A DataLoader gives us batches of examples. A neural network usually processes several images together: this group is a minibatch. DataLoader(dataset, batch_size=64, ...) retrieves examples from the Dataset and stacks them. It also collects their labels into a torch.long tensor.
$$ \text{image}: (1,28,28),\qquad \text{batch}: (B,1,28,28),\qquad \text{labels}: (B,). $$
Here, B is the number of examples in the batch. A loop such as for images, labels in loader: visits the batches; one complete pass is an epoch. next(iter(loader)) retrieves just the first batch.
Implement make_loaders, returning training and validation DataLoaders:
- Pass the corresponding Dataset and
batch_sizeto each loader. - Use
shuffle=Truefor training, so examples appear in a new order each epoch. Give itgenerator=torch.Generator().manual_seed(seed): a separate random-number generator makes this sequence of orders repeatable. - Use
shuffle=Falsefor validation. Setnum_workers=0(load in this Python process) anddrop_last=False(keep the smaller last batch) for both.
def make_loaders(train_data, validation_data, batch_size=64, seed=0):
# TODO: construct and return (training_loader, validation_loader).
raise NotImplementedError("make_loaders")
Check that your loaders preserve the smaller final batch and keep validation examples in order.
check("data", globals())
Inspect one minibatch: images should have shape (64, 1, 28, 28), with 64 integer labels.
train_loader, validation_loader = make_loaders(
train_data=train_dataset,
validation_data=validation_dataset,
batch_size=64,
seed=0,
)
images, labels = next(iter(train_loader))
print("Image batch shape:", tuple(images.shape))
print("Image dtype:", images.dtype)
print("Label batch shape:", tuple(labels.shape))
print("Label dtype:", labels.dtype)
results["data"] = {
"train_size": len(train_dataset),
"validation_size": len(validation_dataset),
"batch_shape": tuple(images.shape),
"image_dtype": str(images.dtype),
"label_dtype": str(labels.dtype),
}
Display the loaded digits and check that their labels look correct.
support.show_images(images=images, labels=labels)
plt.show()
The short diagnostic experiments reuse these saved image tensors for speed. The three main models in Part 5 will train through your DataLoaders.
# Cache images for the short diagnostic experiments only.
probe_train_data = support.cache_dataset(dataset=train_dataset)
probe_validation_data = support.cache_dataset(dataset=validation_dataset)
print("Diagnostic image shape:", tuple(probe_train_data.tensors[0].shape))
unsqueeze(0) adds a size-one axis at the front. For an image (1,28,28), this gives a one-image batch (1,1,28,28). For display, image.squeeze(0) removes the unbatched image's channel axis. Bare squeeze() removes every size-one axis, including a size-one batch axis.
stack adds an axis; cat joins an existing axis. Predict the shapes below before running.
first_two = torch.stack([train_dataset[0][0], train_dataset[1][0]], dim=0)
print("stacked:", first_two.shape)
print("single-image batch:", image.unsqueeze(0).shape)
print("for a grayscale plot:", image.squeeze(0).shape)
1.2 Give each image ten class scores · 4 points¶
A model maps input pixels to class scores. Its parameters are the weights and biases that training changes. Implement flatten_images using the actual batch size, including batches of one. Preserve each image's pixel order. Then implement linear_scores and predict_classes. The ten class scores are called logits.
$$ (B,1,28,28)\longrightarrow(B,784)\longrightarrow(B,10), $$ $$ X\in\mathbb{R}^{B\times784},\quad W\in\mathbb{R}^{10\times784},\quad b\in\mathbb{R}^{10},\qquad Z=XW^\top+b. $$
Weight rows represent classes. @ multiplies matrices; * multiplies entries. Bias broadcasts across examples. argmax(dim=1) returns the index of the largest score in each row: one predicted class per image.
Transpose exchanges axes; reshape regroups values. In reshape(2, -1), PyTorch infers the second size from the number of entries. Inspect the difference below. permute reorders multiple axes. Reordering can change how values are laid out in memory; reshape can copy when needed, whereas view cannot always do so.
Compare element order after reshaping and transposing this non-square tensor.
mini = torch.arange(6).reshape(2, 3)
print("original:\n", mini)
print("reshape(3,2):\n", mini.reshape(3, 2))
print("reshape(2,-1) shape:", mini.reshape(2, -1).shape)
print("transpose:\n", mini.transpose(0, 1))
print("channel-last shape (for some display tools):", images.permute(0, 2, 3, 1).shape)
def flatten_images(images):
# TODO: preserve the example axis; do not hard-code batch size 64.
raise NotImplementedError("flatten_images")
def linear_scores(x, weight, bias):
# x: (B,D), weight: (C,D), bias: (C,) -> scores: (B,C).
raise NotImplementedError("linear_scores")
def predict_classes(logits):
# TODO: return a long tensor of shape (B,).
raise NotImplementedError("predict_classes")
Check that flattening preserves every pixel, including for a one-image batch. These untrained scores only test shapes.
flat = flatten_images(images)
torch.testing.assert_close(flat[0].reshape(1, 28, 28), images[0])
print("Flattened:", flat.shape, "| one-image batch:", flatten_images(images[:1]).shape)
weight = torch.zeros(10, 784)
bias = torch.linspace(-0.2, 0.2, 10)
scores = linear_scores(flat, weight, bias)
print("Scores:", scores.shape, "| predictions:", predict_classes(scores)[:8])
1.3 Turn scores into a stable learning signal · 4 points¶
A loss is a number measuring prediction error; training tries to reduce it. Cross-entropy penalizes low probability for the correct class. A gradient tells us how the loss changes with each score or parameter.
Softmax converts logits to probabilities by exponentiating and normalizing. Subtract each row's maximum first, keeping its axis, to avoid overflow; this leaves the probabilities unchanged.
Compute mean cross-entropy and its logit gradient:
$$ s_{ic}=z_{ic}-\max_k z_{ik},\qquad p_{ic}=\frac{\exp(s_{ic})}{\sum_k\exp(s_{ik})}, $$ $$ L=\frac1B\sum_i\left[\log\sum_c\exp(s_{ic})-s_{i,y_i}\right],\qquad \frac{\partial L}{\partial z_{ic}}=\frac{p_{ic}-\mathbf1[c=y_i]}B. $$
Here, the indicator is 1 for the correct class and 0 otherwise. Use torch.exp, torch.log, and reductions such as sum(dim=1, keepdim=True). Paired row/class indexing selects each correct-class score.
Return (scalar_loss, dlogits) without changing the input; the loss is a zero-dimensional tensor. Use tensor operations only: no F.cross_entropy, softmax, log_softmax, autograd (automatic differentiation) or loss module.
In the later library comparison, pass raw logits to F.cross_entropy.
def cross_entropy_manual(logits, y):
# TODO: stable mean loss and its (B,C) gradient, including the factor 1/B.
raise NotImplementedError("cross_entropy_manual")
Test your forward path and stable loss on different batch sizes and large logits.
check("predictions", globals())
Compare with PyTorch: the loss difference and each gradient-row sum should be near zero.
large_scores = torch.tensor(
[[1000.0, 1001.0, 999.0], [900.0, 898.0, 901.0]],
dtype=torch.float64,
)
example_labels = torch.tensor([1, 0], dtype=torch.long)
loss, score_gradient = cross_entropy_manual(large_scores, example_labels)
library_loss = F.cross_entropy(large_scores, example_labels)
print(f"Your stable cross-entropy: {loss.item():.6f}")
print(f"Library cross-entropy: {library_loss.item():.6f}")
print(f"Absolute loss difference: {abs(loss.item() - library_loss.item()):.3e}")
print("Gradient sum for each example:", score_gradient.sum(dim=1))
This supplied linear classifier uses autograd to compute its gradients. Observe its accuracy after 30 updates; later, your manual gradients will drive training.
# Supplied preview only: autograd generates this linear classifier's gradients.
preview = support.linear_preview(
train_data=probe_train_data,
validation_data=probe_validation_data,
steps=30,
seed=0,
)
print("Training updates:", preview["steps"])
print(f"Validation accuracy after the preview: {preview['validation_accuracy']:.2%}")
2. Hidden layers and backpropagation · 20 points¶
2.1 What does a hidden layer add? · 2 points¶
A hidden layer transforms the input before the output layer produces class scores. Implement combine_linear_layers to return equivalent (weight, bias) for two linear layers without an intervening nonlinearity. Use output-by-input weights and support varying dimensions.
$$ H=XW_1^\top+b_1,\qquad Z=HW_2^\top+b_2. $$
A linear layer with bias preserves midpoints. ReLU keeps positive inputs and replaces negative inputs with zero. Compare ReLU at −1, 0 and 1 to show why a nonlinearity changes what the network can represent; disagreement with one chosen linear layer would not establish this limitation.
def combine_linear_layers(w1, b1, w2, b2):
# TODO: return the single equivalent (weight,bias).
raise NotImplementedError("combine_linear_layers")
The two linear implementations should agree. The ReLU midpoint gap should be 0.5, demonstrating a different representation.
probe_params = support.manual_parameters(
dims=(4, 5, 3),
seed=7,
dtype=torch.float64,
)
probe_x = torch.randn(6, 4, dtype=torch.float64)
combined_weight, combined_bias = combine_linear_layers(
w1=probe_params["W1"],
b1=probe_params["b1"],
w2=probe_params["W2"],
b2=probe_params["b2"],
)
hidden_without_relu = linear_scores(probe_x, probe_params["W1"], probe_params["b1"])
two_layer_scores = linear_scores(
hidden_without_relu, probe_params["W2"], probe_params["b2"]
)
one_layer_scores = linear_scores(probe_x, combined_weight, combined_bias)
collapse_error = (two_layer_scores - one_layer_scores).abs().max().item()
relu_inputs = torch.tensor([-1.0, 0.0, 1.0])
relu_outputs = F.relu(relu_inputs)
midpoint_gap = abs((relu_outputs[0] + relu_outputs[2]) / 2 - relu_outputs[1]).item()
results["representations"] = {
"collapse_max_error": collapse_error,
"midpoint_gap": midpoint_gap,
}
print(f"Two-linear-layer collapse error: {collapse_error:.3e}")
print("Three collinear inputs:", relu_inputs)
print("Their ReLU outputs:", relu_outputs)
print(f"Midpoint-identity gap after ReLU: {midpoint_gap:.3f}")
2.2 Implement the hidden layer and chain rule · 12 points¶
Build a multilayer perceptron (MLP): 784 input features → 64 hidden units → 10 class scores, with ReLU in the hidden layer. Also support smaller test dimensions. Supplied manual_parameters creates the initial tensors.
$$ S=XW_1^\top+b_1,\quad H=\max(0,S),\quad Z=HW_2^\top+b_2. $$
manual_mlp(params,x,y=None) uses W1, b1, W2, b2 with output-by-input weights. Without labels, return logits. With labels, return {'logits': ..., 'loss': ..., 'grads': ...}; grads uses the same four keys.
Backpropagation applies the chain rule from the output toward the input. Each gradient has the same shape as the quantity it differentiates; for example, the logit gradient below is the tensor returned in Part 1. Compute:
$$ G_H=G_ZW_2,\qquad G_S=G_H\odot\mathbf1[S>0], $$ $$ G_{W_2}=G_Z^\top H,\quad G_{b_2}=\sum_iG_{Z,i},\qquad G_{W_1}=G_S^\top X,\quad G_{b_1}=\sum_iG_{S,i}. $$
ReLU passes the incoming gradient only for positive inputs; choose zero at zero. Bias gradients sum over examples. The loss gradient already includes division by batch size.
Use tensor operations, without autograd, nn.Linear or library backward operations. Your gradients will train MNIST in Part 3.
def manual_mlp(params, x, y=None):
# x has shape (batch_size, input_features).
# Step 1: compute hidden preactivations, ReLU activations, and class logits.
hidden_scores = None # TODO: first linear layer
hidden_activations = None # TODO: ReLU
logits = None # TODO: second linear layer
raise NotImplementedError(
"Complete the forward computation, then remove this line."
)
if y is None:
return logits
# Step 2: obtain mean loss and its derivative with respect to the logits.
loss, score_gradient = cross_entropy_manual(logits, y)
# Step 3: send the derivative back through the output weights and ReLU.
hidden_gradient = None # TODO: gradient with respect to hidden activations
hidden_score_gradient = None # TODO: apply the ReLU derivative mask
# Step 4: reduce over examples to obtain the four parameter gradients.
grads = {
"W1": None, # TODO: shape matches params['W1']
"b1": None, # TODO: shape matches params['b1']
"W2": None, # TODO: shape matches params['W2']
"b2": None, # TODO: shape matches params['b2']
}
return {"logits": logits, "loss": loss, "grads": grads}
2.3 Check against autograd¶
Autograd is PyTorch's automatic differentiation system. With requires_grad=True, it records the operations used to produce a tensor. In the example below, loss.backward() computes the derivative and adds it to value.grad.
Run this example: for a squared value of 3, the derivative is 6. A second backward pass adds another 6. Clear .grad before computing the next gradient; otherwise updates use accumulated values.
value = torch.tensor(3.0, requires_grad=True)
loss = value.square()
loss.backward()
print("After one backward:", value.grad.item())
loss = value.square() # A fresh forward computation builds a new graph.
loss.backward()
print("After two backwards:", value.grad.item())
value.grad = None
value.square().backward()
print("After clearing and recomputing:", value.grad.item())
The supplied comparison checks your MLP's predictions, loss, gradients and one weight update against PyTorch. detach() disconnects a tensor from its computation graph; clone() makes an independent copy. torch.no_grad() lets us update weights without recording the update itself for differentiation.
Check the forward values and all four parameter gradients before inspecting their numerical errors.
check("manual", globals())
Inspect each comparison error. With this float64 fixture, errors should normally be below 1e-8.
small_params = support.manual_parameters(
dims=(4, 5, 3),
seed=2,
dtype=torch.float64,
)
small_x = torch.randn(6, 4, dtype=torch.float64)
small_y = torch.tensor([0, 1, 2, 0, 2, 1])
results["verification"] = support.verify_manual(
manual_mlp, small_params, small_x, small_y
)
for quantity, maximum_error in results["verification"].items():
print(f"{quantity:>20}: maximum absolute error = {maximum_error:.3e}")
The supplied check perturbs one weight slightly and measures the change in loss. Compare this finite-difference estimate with your analytic derivative.
finite_difference = support.finite_difference_probe(
forward=manual_mlp,
params=small_params,
x=small_x,
y=small_y,
)
print(f"Analytic derivative: {finite_difference['analytic']:.8f}")
print(f"Finite-difference estimate: {finite_difference['numerical']:.8f}")
print(f"Absolute derivative difference: {finite_difference['absolute_error']:.3e}")
2.4 Express the network as a module · 6 points¶
nn.Module is PyTorch's base class for networks and layers. Define TorchMLP, then model = TorchMLP() creates a model. __init__ constructs its layers; forward defines the prediction computation. Calling model(images) runs forward.
Implement TorchMLP(dims=(784,64,10), dropout=0., batchnorm=False). Call super().__init__() to initialize the base class. Create layers in __init__; creating them in forward would reset their weights every call. Registered layers expose their trainable tensors through model.parameters().
nn.Linear(in_features, out_features) owns its weights and bias. Use nn.ModuleList named linears for linear layers and norms for each hidden layer's BatchNorm1d or Identity. Unlike a plain list, ModuleList registers its layers. Identity leaves its input unchanged. BatchNorm normalizes hidden features; dropout randomly zeros activations during training. Set self.dropout = nn.Dropout(dropout) and support other dims tuples.
Flatten while preserving the batch axis. Hidden layers apply linear → normalization → ReLU → dropout; the output layer returns raw logits. Defaults must match the manual model. The supplied comparison copies weights safely with no_grad and copy_.
$$ \#\text{default parameters}=784\cdot64+64+64\cdot10+10=50{,}890. $$
class TorchMLP(nn.Module):
def __init__(self, dims=(784, 64, 10), dropout=0.0, batchnorm=False):
super().__init__()
self.linears = nn.ModuleList()
self.norms = nn.ModuleList()
for input_features, output_features in zip(dims[:-1], dims[1:]):
# TODO: append a registered nn.Linear layer to self.linears.
pass
for hidden_features in dims[1:-1]:
# TODO: append BatchNorm1d or Identity to self.norms.
pass
self.dropout = nn.Dropout(p=dropout)
raise NotImplementedError("Finish registering layers, then remove this line.")
def forward(self, images):
x = None # TODO: flatten images while preserving the batch axis
for linear_layer, normalization in zip(self.linears[:-1], self.norms):
# TODO: linear -> normalization -> ReLU -> dropout.
pass
raise NotImplementedError("Return raw logits from the final linear layer.")
Check parameter registration, output shapes and the one-image case.
check("model", globals())
Copy identical weights into both models. eval() selects evaluation behavior for BatchNorm and dropout; no_grad() separately turns off gradient recording. Expect 50,890 parameters (numel() counts tensor entries) and matching logits.
bridge_params = support.manual_parameters(seed=3)
bridge_model = TorchMLP()
bridge_model.eval()
support.load_manual_weights(model=bridge_model, params=bridge_params)
with torch.no_grad():
module_logits = bridge_model(images)
manual_logits = manual_mlp(bridge_params, flatten_images(images))
torch.testing.assert_close(module_logits, manual_logits)
parameter_count = sum(parameter.numel() for parameter in bridge_model.parameters())
print("Registered parameter count:", parameter_count)
print("Expected parameter count:", 50890)
maximum_logit_difference = (module_logits - manual_logits).abs().max().item()
print(f"Maximum module/manual logit difference: {maximum_logit_difference:.3e}")
nn.Sequential passes each layer's output to the next. This example expresses the default model as a simple chain; Flatten(start_dim=1) keeps the batch axis.
sequential_example = nn.Sequential(
nn.Flatten(start_dim=1),
nn.Linear(in_features=784, out_features=64),
nn.ReLU(),
nn.Linear(in_features=64, out_features=10),
)
3. Optimization and training · 22 points¶
3.1 Make gradients change the weights · 3 points¶
An optimizer updates weights using their gradients. The learning rate controls the step size; subtracting the gradient moves toward lower loss for a sufficiently small step. Momentum keeps a velocity across updates:
$$ v_t=\mu v_{t-1}+g_t,\qquad \theta_{t+1}=\theta_t-\eta v_t,\qquad v_0=0. $$
Implement momentum_step: lr is the learning rate and beta is the momentum coefficient. Return (new_weight, {'velocity': new_velocity}). Initialize velocity with torch.zeros_like(w) only when state is absent; otherwise reuse the previous velocity. Do not modify the caller's tensors or state. beta=0 gives SGD. The supplied MNIST driver uses your manual gradients and updates.
def momentum_step(w, grad, state=None, lr=0.01, beta=0.9):
# TODO: persistent velocity; return new tensors without mutating the inputs.
raise NotImplementedError("momentum_step")
3.2 Implement AdamW · 4 points¶
Adam keeps moving averages of gradients and squared gradients, called moments. AdamW shrinks weights separately using weight_decay. Start with zero moments and counter:
$$ m_t=\beta_1m_{t-1}+(1-\beta_1)g_t,\qquad v_t=\beta_2v_{t-1}+(1-\beta_2)g_t^2, $$ $$ \widehat m_t=\frac{m_t}{1-\beta_1^t},\qquad \widehat v_t=\frac{v_t}{1-\beta_2^t},\qquad w_t=(1-\eta\lambda)w_{t-1}- \eta\frac{\widehat m_t}{\sqrt{\widehat v_t}+\epsilon}. $$
Implement adamw_step, returning (new_weight, new_state) with state keys m, v, and integer t. Increment t once, correct both moment biases, and keep epsilon outside the square root. Weight decay must not enter the moments. Preserve inputs and state; do not call a library optimizer.
Complete W3's arithmetic, then compare two updates and optimizer states with PyTorch below.
def adamw_step(
w,
grad,
state=None,
lr=0.001,
betas=(0.9, 0.999),
eps=1e-8,
weight_decay=0.01,
):
beta1, beta2 = betas
# Step 1: read persistent state, or initialize zero moments and counter zero.
old_m = None # TODO: tensor with the same shape/dtype/device as w
old_v = None # TODO: tensor with the same shape/dtype/device as w
old_t = None # TODO: integer number of previous updates
# Step 2: update the moments using grad alone; keep weight decay separate.
new_m = None # TODO
new_v = None # TODO
new_t = None # TODO
# Step 3: apply bias correction and construct the adaptive gradient update.
corrected_m = None # TODO
corrected_v = None # TODO
new_weight = None # TODO: decoupled decay plus the adaptive update
raise NotImplementedError("Complete the three stages, then remove this line.")
return new_weight, {"m": new_m, "v": new_v, "t": new_t}
Compare your AdamW weights and moments with PyTorch after two updates. nn.Parameter wraps a tensor as a trainable parameter. torch.optim.AdamW takes the parameters to update; optimizer.step() reads their .grad values. Here we supply those gradients directly. The second update checks that both implementations retain their state.
manual_weight = torch.tensor(1.0, dtype=torch.float64)
manual_state = None
library_weight = nn.Parameter(manual_weight.clone())
library_optimizer = torch.optim.AdamW(
[library_weight],
lr=0.1,
betas=(0.5, 0.5),
eps=1e-8,
weight_decay=0.2,
)
for step_number, gradient_value in enumerate([2.0, 0.0], start=1):
gradient = torch.tensor(gradient_value, dtype=torch.float64)
manual_weight, manual_state = adamw_step(
w=manual_weight,
grad=gradient,
state=manual_state,
lr=0.1,
betas=(0.5, 0.5),
eps=1e-8,
weight_decay=0.2,
)
library_weight.grad = gradient.clone()
library_optimizer.step()
library_state = library_optimizer.state[library_weight]
torch.testing.assert_close(manual_weight, library_weight.detach())
torch.testing.assert_close(manual_state["m"], library_state["exp_avg"])
torch.testing.assert_close(manual_state["v"], library_state["exp_avg_sq"])
assert manual_state["t"] == step_number
print(
f"Step {step_number}: weight={manual_weight.item():.6f}, "
f"m={manual_state['m'].item():.6f}, v={manual_state['v'].item():.6f}, "
f"counter={manual_state['t']}"
)
print("Both parameter updates and persistent moments match PyTorch.")
3.3 Write a reusable training loop · 5 points¶
Implement train_epoch(model,loader,optimizer,label_smoothing=0.) for batches of (images, labels):
- Use
model.train()to enable training behavior in Dropout and BatchNorm; move batch tensors with.to(model_device). - Clear old gradients with
optimizer.zero_grad(set_to_none=True). - Compute raw logits and mean
F.cross_entropywith the requestedlabel_smoothing: zero uses ordinary class targets; a positive value spreads some target probability across all classes. - Call
loss.backward()to fill parameter gradients, thenoptimizer.step()to update weights. - Accumulate the loss, correct predictions, actual example count and number of updates.
Return objective, accuracy, count, updates. Weight batch losses by example count, including a short final batch. AdamW decay is not an extra loss penalty. For logging, .item() extracts a Python number and .detach() produces a tensor without gradient tracking; keep both out of the loss computation.
def train_epoch(model, loader, optimizer, label_smoothing=0.0):
model.train()
model_device = next(model.parameters()).device
total_objective = 0.0
correct = 0
count = 0
updates = 0
for images, labels in loader:
images = images.to(model_device)
labels = labels.to(model_device)
# Step 1: clear old gradients.
optimizer.zero_grad(set_to_none=True)
# Step 2: compute raw logits and the mean training objective.
logits = None # TODO: call the model
loss = None # TODO: cross-entropy with the requested label_smoothing
raise NotImplementedError(
"Complete this minibatch update, then remove this line."
)
# Step 3: backpropagate, then update registered parameters.
# TODO: call backward and optimizer.step in the correct order.
# Step 4: log detached values, weighted by the actual batch size.
batch_count = labels.shape[0]
# TODO: update total_objective, correct, count, and updates.
return {
"objective": total_objective / count,
"accuracy": correct / count,
"count": count,
"updates": updates,
}
3.4 Evaluate a fixed model · 4 points¶
model.eval() changes Dropout and BatchNorm behavior; torch.no_grad() disables gradient recording. Use both when evaluating.
Implement evaluate(model,loader): save model.training, evaluate under no_grad, then restore the saved mode. Compute ordinary cross-entropy against the integer class labels, without smoothing or a regularization penalty. Weight losses by example count: batch sizes 4 and 1 with mean losses 1 and 3 give 1.4, not 2.
Return loss, accuracy, count, and CPU tensors logits, targets concatenated in loader order with torch.cat. Preserve parameters and existing gradients.
def evaluate(model, loader):
incoming_training_mode = model.training
model.eval()
model_device = next(model.parameters()).device
total_loss = 0.0
correct = 0
count = 0
all_logits = []
all_targets = []
try:
with torch.no_grad():
for images, labels in loader:
images = images.to(model_device)
labels = labels.to(model_device)
logits = None # TODO: call the model
# TODO: accumulate hard-label loss, correct predictions and count.
# Use a sum of example losses, not an unweighted sum of batch means.
# TODO: append logits and labels on CPU to their corresponding lists.
raise NotImplementedError("Complete evaluation for one minibatch.")
finally:
# Restore the caller's mode even if a check reports an error.
model.train(incoming_training_mode)
return {
"loss": total_loss / count,
"accuracy": correct / count,
"count": count,
"logits": None, # TODO: concatenate all_logits along the batch axis
"targets": None, # TODO: concatenate all_targets along the batch axis
}
Test your optimizer updates and example-weighted evaluation before training on MNIST.
check("training", globals())
3.5 Train across epochs and keep the best model · 2 points¶
An epoch is one pass through the training loader. Implement fit_model to repeat training and validation for epochs passes. Create torch.optim.AdamW(model.parameters(), ...) once before the loop, using config['lr'] and config['weight_decay']; its moments must persist across epochs.
After each epoch, evaluate both datasets with ordinary cross-entropy. The supplied ordered training loader measures training loss without changing the shuffled loader's next order. Record the metrics using the code below.
A checkpoint is a saved model state. Whenever validation loss improves, copy model.state_dict() with copy.deepcopy(...). This dictionary contains weights and other saved tensors; copying prevents later training from changing your best checkpoint. After all epochs, restore it with model.load_state_dict(...). We will use this function for all three models in Part 5.
def fit_model(model, train_loader, validation_loader, config, epochs=6):
optimizer = None # TODO: AdamW using this model's parameters and config.
training_evaluation_loader = DataLoader(
train_loader.dataset,
batch_size=train_loader.batch_size,
shuffle=False,
num_workers=0,
drop_last=False,
)
# Supplied records for comparing the runs later.
initial_state = support.state_copy(model)
initial_state_digest = support.tensor_digest(initial_state)
order_digest = support.loader_order_digest(train_loader, epochs=epochs)
parameter_count = sum(p.numel() for p in model.parameters())
history = []
updates = 0
best_loss = math.inf
best_epoch = None
best_state = None
for epoch in range(1, epochs + 1):
trained = None # TODO: train_epoch, including config['label_smoothing'].
training = None # TODO: evaluate on training_evaluation_loader.
validation = None # TODO: evaluate on validation_loader.
raise NotImplementedError("Complete the epoch and checkpoint steps.")
updates += trained["updates"]
history.append(
{
"epoch": epoch,
"training_objective": trained["objective"],
"training": {k: training[k] for k in ("loss", "accuracy", "count")},
"validation": {k: validation[k] for k in ("loss", "accuracy", "count")},
}
)
# TODO: if validation loss improves, update best_loss, best_epoch,
# and an independent copy of model.state_dict() in best_state.
# TODO: restore best_state into the model.
model.eval()
training = evaluate(model, training_evaluation_loader)
validation = evaluate(model, validation_loader)
return {
"config": copy.deepcopy(config),
"history": history,
"training": {k: training[k] for k in ("loss", "accuracy", "count")},
"validation": {k: validation[k] for k in ("loss", "accuracy", "count")},
"updates": updates,
"parameter_count": parameter_count,
"initial_state": initial_state,
"initial_state_digest": initial_state_digest,
"order_digest": order_digest,
"best_epoch": best_epoch,
"best_state": best_state,
"model": model,
"train_loader": train_loader,
"validation_loader": validation_loader,
"training_evaluation_loader": training_evaluation_loader,
}
Check that your fit loop keeps optimizer state across epochs and restores the best validation checkpoint.
check("fit", globals())
3.6 Check trainability on a tiny set · 2 points¶
Choose SANITY_CHECK_LR for AdamW with zero decay on 32 fixed examples. Use at most 150 full-subset updates per attempt and three attempts. The supplied helper records the model and curve.
Falling loss and high training accuracy show fitting, not generalization. If training fails, inspect parameter changes, gradients and learning rate. Report your final attempt and any informative failures briefly; a supported diagnosis can earn credit even without reaching a numerical target.
SANITY_CHECK_LR = (
0.003 # TODO: choose a workable learning rate for the 32-example check.
)
Fit the 32-example subset. Compare initial/final loss and accuracy; this checks training, not generalization.
results["tiny"] = support.tiny_subset_fit(
model_class=TorchMLP,
train_epoch=train_epoch,
evaluate=evaluate,
train_data=probe_train_data,
lr=SANITY_CHECK_LR,
steps=150,
seed=0,
)
print(f"Initial tiny-subset loss: {results['tiny']['initial_loss']:.6f}")
print(f"Final tiny-subset loss: {results['tiny']['final_loss']:.6f}")
print(f"Final tiny-subset accuracy: {results['tiny']['accuracy']:.2%}")
print("Learning rate:", results["tiny"]["lr"])
print("Training updates:", results["tiny"]["steps"])
Plot the recorded tiny-subset losses. Look for a downward trend or signs of an unsuitable learning rate.
plt.figure(figsize=(6, 3))
plt.plot(results["tiny"]["history"])
plt.xlabel("Update")
plt.ylabel("Tiny-subset training loss")
plt.title("Can this model fit 32 examples?")
plt.tight_layout()
plt.show()
3.7 Compare update rules · 2 points¶
Choose separate learning rates for SGD, momentum and AdamW. Use identical initial weights, the same 80 minibatches and no regularization. All use your manual gradients; SGD/momentum call momentum_step and AdamW calls adamw_step. One repeat is allowed to repair unsuitable rates.
Record the rates and give one sentence interpreting the curves. These selected rates and 80 updates cannot establish a universal optimizer ranking; no optimizer must win.
OPTIMIZER_RATES = {
"sgd": 0.1,
"momentum": 0.03,
"adamw": 0.003,
} # TODO: choose and inspect.
Train with all three update rules on identical weights and minibatches. Compare rates and initial/final loss.
results["optimizers"] = support.optimizer_comparison(
forward=manual_mlp,
momentum_step=momentum_step,
adamw_step=adamw_step,
train_data=probe_train_data,
rates=OPTIMIZER_RATES,
steps=80,
seed=0,
)
print(
f"{'Optimizer':<12} {'Learning rate':>14} "
f"{'Initial loss':>14} {'Final loss':>12} {'Updates':>9}"
)
for optimizer_name, run in results["optimizers"].items():
print(
f"{optimizer_name:<12} {run['lr']:>14.4f} "
f"{run['initial_loss']:>14.4f} {run['final_loss']:>12.4f} {run['steps']:>9d}"
)
Plot those same runs. Use the trends and loss table to support your short observation.
support.plot_optimizer_curves(runs=results["optimizers"])
plt.show()
Optimizer comparison — one sentence
[Compare your measured curves and learning rates.]
4. Initialization and normalization · 14 points¶
4.1 Apply a fan-in rule · 4 points¶
Initialization chooses the starting parameter values, before any training. For nn.Linear, weight has shape (out_features, in_features); fan-in is the number of inputs. Use He-normal weights for ReLU hidden layers and a smaller variance for the linear output:
$$ \sigma_{\mathrm{hidden}}=\sqrt{2/\mathrm{fan\_in}},\qquad \sigma_{\mathrm{output}}=\sqrt{1/\mathrm{fan\_in}}. $$
Implement he_initialize(model,seed=0) in place. Visit the layers in model.linears: fill each .weight using normal_(mean=0, std=..., generator=...), and clear its .bias with zero_(). A trailing underscore marks an operation that changes the existing tensor. Use a seeded torch.Generator and perform these writes under no_grad().
For BatchNorm, set its learned scale (weight) to one and shift (bias) to zero; call reset_running_stats() to reset its estimates of feature means and variances. No return value is needed.
The symmetry example below makes both incoming rows and outgoing columns identical, so hidden-unit gradients remain identical. Matching incoming weights alone would not establish this.
def he_initialize(model, seed=0):
# TODO: reproducible hidden/output scaling, zero biases, reset any BatchNorm state.
raise NotImplementedError("he_initialize")
With identical incoming and outgoing weights, matching hidden units should receive identical gradients.
symmetry = support.symmetry_demo()
print(
f"Incoming-weight gradient row difference: {symmetry['incoming_gradient_row_gap']:.3e}"
)
print(
f"Outgoing-weight gradient column difference: {symmetry['outgoing_gradient_column_gap']:.3e}"
)
4.2 Diagnose and repair initialization · 4 points¶
Inspect a poorly learning deeper MLP. Activation standard deviation measures the spread of hidden-layer outputs; inactive fraction counts zero ReLU outputs; gradient norm measures the overall size of a layer's gradients. The supplied experiment compares the original initialization with your repair on the same batch, then runs 40 identical SGD updates.
Use one before/after measurement to explain whether signals or gradients shrink through the layers, and compare the losses after training.
Compare layer statistics and losses before and after initialization repair, using the same images and 40 updates.
results["initialization"] = support.initialization_investigation(
model_class=TorchMLP,
initializer=he_initialize,
train_epoch=train_epoch,
evaluate=evaluate,
train_data=probe_train_data,
steps=40,
seed=0,
)
print(
f"{'Condition':<10} {'Layer':>6} {'Activation std':>16} "
f"{'Inactive fraction':>19} {'Gradient norm':>16}"
)
for condition in ["before", "after"]:
for layer_metrics in results["initialization"][condition]:
print(
f"{condition:<10} {layer_metrics['layer']:>6d} "
f"{layer_metrics['activation_std']:>16.3e} "
f"{layer_metrics['inactive_fraction']:>18.2%} "
f"{layer_metrics['gradient_norm']:>16.3e}"
)
original_initialization_loss = results["initialization"]["before_loss"]
print(
f"Loss after 40 updates, original initialization: {original_initialization_loss:.4f}"
)
print(
f"Loss after 40 updates, repaired initialization: {results['initialization']['after_loss']:.4f}"
)
Initialization — 1–2 sentences
[Use one before/after measurement to explain the effect of initialization.]
4.3 Calculate BatchNorm and inspect its modes · 3 points¶
BatchNorm normalizes each feature using the current batch during training. For a tensor of shape (B, features), implement:
$$ \mu_j=\frac1B\sum_i x_{ij},\qquad v_j=\frac1B\sum_i(x_{ij}-\mu_j)^2,\qquad z_{ij}=\gamma_j\frac{x_{ij}-\mu_j}{\sqrt{v_j+\epsilon}}+\beta_j. $$
The vectors gamma and beta are learned scale and shift. Use mean(dim=0) and var(dim=0, correction=0); the latter divides squared deviations by the batch size. PyTorch uses this variance for the training output, but divides by B-1 when updating its running variance.
The demonstration changes one image's batch companions, then evaluates using saved running means and variances. Follow its train(), eval() and no_grad() calls: no_grad() alone does not stop training-mode running-statistic updates.
def batchnorm_calculation(x, gamma, beta, eps=1e-5):
feature_mean = None # TODO: mean across examples, one value per feature
feature_variance = None # TODO: population variance across examples
normalized = None # TODO: center and divide by standard deviation
raise NotImplementedError("Apply learned scale and shift, then return the result.")
Compare your normalization with PyTorch; the float64 output difference should be below about 1e-12. named_parameters() lists weights learned through gradients. named_buffers() lists other saved tensors, including BatchNorm's running means and variances.
bn_layer, batch_a, batch_b = support.batchnorm_fixture()
print("Learned parameters:", [name for name, _ in bn_layer.named_parameters()])
print("Running-state buffers:", [name for name, _ in bn_layer.named_buffers()])
manual_bn = batchnorm_calculation(
x=batch_a,
gamma=bn_layer.weight,
beta=bn_layer.bias,
eps=bn_layer.eps,
)
library_bn_layer = copy.deepcopy(bn_layer)
library_bn_layer.train()
with torch.no_grad():
library_bn = library_bn_layer(batch_a)
normalization_error = (manual_bn - library_bn).detach().abs().max().item()
print("Your normalized output:\n", manual_bn.detach())
print(f"Maximum normalization error: {normalization_error:.3e}")
results["batchnorm"] = {"manual_error": normalization_error}
Change the query’s batch companions in training mode. Observe its output and the running statistics, even under no_grad().
training_layer_a = copy.deepcopy(bn_layer)
training_layer_b = copy.deepcopy(bn_layer)
training_layer_a.train()
training_layer_b.train()
buffers_before_training = {
name: value.detach().clone() for name, value in training_layer_a.named_buffers()
}
with torch.no_grad():
training_output_a = training_layer_a(batch_a)
training_output_b = training_layer_b(batch_b)
training_query_difference = (
(training_output_a[0] - training_output_b[0]).abs().max().item()
)
training_buffer_changes = {
name: not torch.equal(buffers_before_training[name], value)
for name, value in training_layer_a.named_buffers()
}
print(f"Query-output difference in training mode: {training_query_difference:.6f}")
for name, changed in training_buffer_changes.items():
print(f"{name} changed under no_grad: {changed}")
results["batchnorm"]["train_companion_gap"] = training_query_difference
results["batchnorm"]["running_mean_changed_under_no_grad"] = training_buffer_changes[
"running_mean"
]
Switch to evaluation mode and reuse one frozen state. The query output should no longer depend on its companions.
evaluation_layer = training_layer_a
evaluation_layer.eval()
buffers_before_evaluation = {
name: value.detach().clone() for name, value in evaluation_layer.named_buffers()
}
with torch.no_grad():
evaluation_output_a = evaluation_layer(batch_a)
evaluation_output_b = evaluation_layer(batch_b)
evaluation_query_difference = (
(evaluation_output_a[0] - evaluation_output_b[0]).abs().max().item()
)
evaluation_buffers_unchanged = all(
torch.equal(buffers_before_evaluation[name], value)
for name, value in evaluation_layer.named_buffers()
)
print(f"Query-output difference in evaluation mode: {evaluation_query_difference:.3e}")
print("All running-state buffers unchanged:", evaluation_buffers_unchanged)
results["batchnorm"]["eval_companion_gap"] = evaluation_query_difference
results["batchnorm"]["eval_buffers_unchanged"] = evaluation_buffers_unchanged
BatchNorm training needs more than one observation per feature; single-image checks use evaluation mode. Run these public checks of initialization and feature-wise normalization.
check("stability", globals())
4.4 Train a BatchNorm MLP · 3 points¶
Train this model in Part 5 alongside the baseline: a 784 → 64 → 10 MLP with BatchNorm before its hidden ReLU. Match the baseline's initial linear weights, batch order, learning rate and six-epoch budget.
BatchNorm's learned parameters must update, its running statistics must evolve during training, and validation must use frozen statistics. Compare its curve and final validation metrics with the baseline; improved accuracy is not required.
5. Generalization and model selection · 12 points¶
5.1 Compute dropout · 3 points¶
Dropout randomly removes activations during training; p is the probability of dropping each entry. Implement inverted_dropout(x,mask,p,training=True) for 0 ≤ p < 1, using the supplied zero/one mask with the same shape as x:
$$ \widetilde h=h\odot m/(1-p)\quad\text{during training},\qquad \widetilde h=h\quad\text{during evaluation}. $$
A zero in the mask drops that entry. Do not draw a new mask here; nn.Dropout draws masks in the real model. Scaling preserves each activation's expected value.
Label smoothing softens targets: three classes, smoothing 0.3, and class 1 give (0.1,0.8,0.1) in PyTorch. Its loss uses all three log probabilities. Model comparisons still use ordinary, unsmoothed evaluation loss.
def inverted_dropout(x, mask, p, training=True):
# TODO: fixed-mask training calculation; identity values at evaluation.
raise NotImplementedError("inverted_dropout")
Inspect the fixed-mask outputs: training drops or rescales activations; evaluation leaves them unchanged.
activation = torch.tensor([[1.0, -2.0, 3.0], [0.0, 4.0, -1.0]])
fixed_mask = torch.tensor([[1.0, 0.0, 1.0], [0.0, 1.0, 0.0]])
results["dropout"] = {
"training": inverted_dropout(activation, fixed_mask, p=0.25, training=True),
"evaluation": inverted_dropout(activation, fixed_mask, p=0.25, training=False),
}
print("Original activations:\n", activation)
print("Fixed keep-mask:\n", fixed_mask)
print("Training output:\n", results["dropout"]["training"])
print("Evaluation output:\n", results["dropout"]["evaluation"])
5.2 Compare three candidates using validation · 6 points¶
Use your fit_model to train each model below for six epochs with batch size 64. Compare their training and validation losses to see how well learning carries over to unseen images.
| Run | Setting |
|---|---|
| Baseline | 784 → 64 → 10; AdamW learning rate 0.003; no dropout, decay or smoothing |
| Regularized | Choose one of dropout, weight decay or label smoothing and its strength |
| BatchNorm | Add BatchNorm before the hidden ReLU; explicit regularizers remain zero |
Change only the stated setting in each comparison. Match initial linear weights with he_initialize(model, seed=seed). For each model, call make_loaders with the same seed to start its shuffle sequence afresh. Reusing an already-iterated loader would start from a different order.
Select the model and epoch with the lowest ordinary, unsmoothed validation loss. Do not select using the training objective or test performance. Give two short observations supported by results: one for regularization, one for BatchNorm. Neither intervention must improve accuracy.
REGULARIZER = "dropout" # TODO: choose dropout, weight_decay, or label_smoothing.
REGULARIZER_STRENGTH = 0.2 # TODO: choose a positive strength below 1.
Implement run_study: create and initialize each TorchMLP, build fresh loaders, and call fit_model. The settings dictionary below supplies each run's arguments. Choose selected using the lowest validation loss among the returned best checkpoints; the test set is not used here.
def run_study(configs, train_data, validation_data, seed=0):
runs = {}
train_loaders = {}
validation_loaders = {}
for name, config in configs.items():
torch.manual_seed(seed)
model = None # TODO: TorchMLP using dims, dropout and batchnorm from config.
# TODO: initialize this model with he_initialize and the supplied seed.
train_loader, validation_loader = None, None
# TODO: make_loaders using config['batch_size'] and seed + 101.
runs[name] = None # TODO: fit_model for six epochs.
raise NotImplementedError("Assemble each run, then remove this line.")
train_loaders[name] = train_loader
validation_loaders[name] = validation_loader
selected = None # TODO: name of the run with the lowest validation loss.
# Supplied records for plots and comparison checks.
validation_x, validation_y = support.loader_tensors(validation_loaders[selected])
regularizer = next(
k
for k in ("dropout", "weight_decay", "label_smoothing")
if configs["regularized"][k] > 0
)
return {
"runs": runs,
"selected": selected,
"models": {name: run["model"] for name, run in runs.items()},
"validation_x": validation_x,
"validation_y": validation_y,
"regularizer": regularizer,
"strength": configs["regularized"][regularizer],
"train_loaders": train_loaders,
"validation_loaders": validation_loaders,
"train_data": train_data,
"validation_data": validation_data,
}
Train the three candidates, then compare their losses and accuracies. Each row reports that run's best validation checkpoint.
baseline_config = {
"dims": (784, 64, 10),
"dropout": 0.0,
"batchnorm": False,
"optimizer": "adamw",
"lr": 0.003,
"weight_decay": 0.0,
"label_smoothing": 0.0,
"batch_size": 64,
}
configs = {
"baseline": baseline_config,
"regularized": {**baseline_config, REGULARIZER: REGULARIZER_STRENGTH},
"batchnorm": {**baseline_config, "batchnorm": True},
}
results["study"] = run_study(configs, train_dataset, validation_dataset, seed=0)
print(
f"{'Run':<14} {'Epoch':>6} {'Train loss':>12} {'Val loss':>12} "
f"{'Val accuracy':>14} {'Updates':>9}"
)
for run_name, run in results["study"]["runs"].items():
print(
f"{run_name:<14} {run['best_epoch']:>6d} "
f"{run['training']['loss']:>12.4f} {run['validation']['loss']:>12.4f} "
f"{run['validation']['accuracy']:>13.2%} {run['updates']:>9d}"
)
print("Selected by validation loss:", results["study"]["selected"])
Plot the recorded learning curves. Use the table and curves to compare regularization and BatchNorm with the baseline.
support.plot_study(study=results["study"])
plt.show()
Study observations — 1–2 sentences each
- Regularization: [Compare with the baseline using your measured results.]
- BatchNorm: [Compare with the baseline using your measured results.]
5.3 Save, reload and test your model · 3 points¶
A model in memory disappears when the kernel stops. Implement finish_study to save the selected configuration and best state with torch.save(checkpoint, path). Reload with torch.load(path, map_location='cpu', weights_only=True). Create a fresh TorchMLP using the saved dims, dropout and batchnorm, restore its tensors with load_state_dict, and switch to eval().
Use evaluate once on the test loader. For a single image, unsqueeze(0) adds the batch axis the model expects. Complete the Boolean mask that selects incorrectly classified examples. The supplied code displays the results; keep this final test separate from choosing the model.
def finish_study(study, test_loader, path="a1_selected_model.pt"):
selected_run = study["runs"][study["selected"]]
checkpoint = {
"config": selected_run["config"],
"state_dict": selected_run["best_state"],
}
# TODO: save checkpoint to path, then load it into saved.
saved = None
config = None # TODO: read the saved configuration.
loaded = None # TODO: create a fresh TorchMLP with that configuration.
# TODO: load the saved state into loaded.
raise NotImplementedError("Complete save/reload, prediction and test evaluation.")
loaded.eval()
# Supplied comparison: before/after reload predictions should match.
test_x, test_y = support.loader_tensors(test_loader)
source_model = study["models"][study["selected"]]
source_model.eval()
with torch.no_grad():
source_logits = source_model(test_x[:16])
loaded_logits = loaded(test_x[:16])
single_image_logits = None # TODO: predict test_x[0] with a batch axis.
test_metrics = None # TODO: evaluate the reloaded model on test_loader.
predictions = None # TODO: predicted class for each row of test_metrics['logits'].
error_mask = None # TODO: compare predictions with test_metrics['targets'].
# Supplied records for reporting and plotting.
study["test"] = {k: test_metrics[k] for k in ("loss", "accuracy", "count")}
return {
"max_logit_error": (source_logits - loaded_logits).abs().max().item(),
"single_image_shape": tuple(single_image_logits.shape),
"loaded_model": loaded,
"source_model": source_model,
"source_logits": source_logits,
"loaded_logits": loaded_logits,
"config": config,
"path": str(path),
"error_mask": error_mask,
"error_images": test_x[error_mask][:8],
"error_targets": test_y[error_mask][:8],
"error_predictions": predictions[error_mask][:8],
"test_x": test_x,
"test_y": test_y,
"test_metrics": test_metrics,
"test_loader": test_loader,
}
Check dropout and restoration into a fresh model before saving your selected checkpoint.
check("generalization", globals())
Create an ordered test loader, then save, reload and evaluate your selected model. Check that the reloaded predictions match and that one-image output has shape (1, 10).
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False, num_workers=0)
results["checkpoint"] = finish_study(results["study"], test_loader)
print(
f"Reloaded-model logit difference: {results['checkpoint']['max_logit_error']:.3e}"
)
print("Single-image output shape:", results["checkpoint"]["single_image_shape"])
print(f"Selected-model test loss: {results['study']['test']['loss']:.4f}")
print(f"Selected-model test accuracy: {results['study']['test']['accuracy']:.2%}")
print("Checkpoint file:", results["checkpoint"]["path"])
Inspect a few mistakes and compare true/predicted labels. Do not use this test inspection to tune another model.
support.show_images(
images=results["checkpoint"]["error_images"],
labels=results["checkpoint"]["error_targets"],
predictions=results["checkpoint"]["error_predictions"],
)
plt.show()
Before submitting¶
- Restart the kernel and run the core notebook in order.
- Preserve function names and the Markdown observation cells.
- Check checkpoint restoration and ground short observations in your results.
- Save the notebook with figures, tables and check outputs visible.
API references: MNIST tutorial, data loading, autograd, BatchNorm, AdamW.