Week 3 — Optimization and Generalization
Week 2 asked whether useful gradients can reach the early layers. Now ask what happens after those gradients arrive: how does an optimizer move, and does the resulting model work on new data?
- Distinguish the source of a gradient from the rule that turns it into an update.
- Trace momentum, RMSProp, and Adam’s state through numerical examples.
- Explain why AdamW, dropout, scheduling, and early stopping change different parts of training.
- Design a controlled comparison using validation for selection and a reserved test set for final evaluation.
A gradient is an estimate of what to change.
For a dataset of N observations, the empirical objective is the average of the individual losses. Full-batch gradient descent uses every observation for one update. Single-example SGD uses one observation; minibatch SGD averages a smaller group. In practice, “SGD” often refers to the minibatch version too.
Full gradient: g = (1/N) Σᵢ ∇ℓᵢ(θ)
Batch estimate: gB = (1/|B|) Σᵢ∈B ∇ℓᵢ(θ)
| Method | Observations per update | Trade-off |
|---|---|---|
| Full batch | All N | Exact gradient of this empirical objective at the current parameters, but potentially costly per update. |
| Single-example SGD | 1 | Frequent, inexpensive updates with high sampling variability. |
| Minibatch SGD | A batch of B | Uses vectorized hardware efficiently while retaining sampling variability. |
Under uniform sampling, a mean of per-example gradients is unbiased for the empirical mean gradient at a fixed parameter value. Larger batches generally reduce its sampling variance. This statement needs care with nonuniform sampling or operations whose outputs depend on the whole batch.
Noise changes the optimization path. It can help a run move through some flat or saddle-like regions, but can also slow convergence. Full-batch does not mean “correct about the population,” and noisy does not automatically mean “generalizes better.”
The gradient changes when the batch changes.
For six observations, Lᵢ(θ) = ½(θ − yᵢ)², so gᵢ = θ − yᵢ. Every equally sized subset is equally likely in this illustration. We enumerate them all, without sampling with replacement.
The mean matches the full gradient; individual batches need not. At θ = 2 the full gradient is zero, yet small batches can still produce updates. This is noise in an estimator of the empirical gradient, not a guarantee of better generalization.
A step size has to fit the geometry.
Backpropagation gives local sensitivity. Plain gradient descent multiplies that gradient by a learning rate and moves in the opposite direction. A very small rate can make progress hard to see; a large rate can oscillate or diverge.
Quadratic example: L(θ) = ½θᵀAθ, ∇L = Aθ
For positive-definite A: 0 < η < 2/λmax(A) gives convergence of plain gradient descent.
When one direction is much steeper than another, a single rate becomes a compromise. A rate small enough for the steep direction may make the flatter direction painfully slow. The quadratic below makes that conditioning visible without pretending to reproduce a complete neural-network loss surface.
One bowl, four update rules.
L(x, y) = ½(x² + c·y²), starting at (2, 1). The same initial gradient goes into different optimizers. After they move, each evaluates the gradient at its own position.
For plain SGD on this bowl, convergence requires 0 < η < 2 / max(1, c). At c = 20, that upper bound is 0.1000. A common learning rate illustrates mechanisms; it is not a tuned optimizer benchmark or a generalization study.
State turns the same gradient into a different update.
Momentum: remember direction
The velocity accumulates past gradients. Persistent signs reinforce each other; alternating signs can partially cancel. State can carry a run forward even when the current gradient becomes small.
θₜ = θₜ₋₁ − ηvₜ
This unnormalized velocity convention matches the slides and ordinary PyTorch SGD momentum with zero dampening. Other presentations put a factor of (1 − β) on gₜ and use a correspondingly different scale.
RMSProp: remember scale
A running average of squared gradients rescales each coordinate. A large second-moment estimate makes the multiplier on that coordinate’s current gradient smaller. This concerns the effective multiplier, not a guarantee that its absolute update is smaller than every other coordinate’s.
θₜ = θₜ₋₁ − ηgₜ / (√sₜ + ε)
Adam: direction and scale, with bias correction
Adam tracks both first and second moments. Starting them at zero depresses early estimates. The correction compensates for that initialization under a stationary moment model; it is not a correction for biased training data.
vₜ = β₂vₜ₋₁ + (1 − β₂)gₜ²
m̂ₜ = mₜ / (1 − β₁ᵗ), v̂ₜ = vₜ / (1 − β₂ᵗ)
θₜ = θₜ₋₁ − ηm̂ₜ / (√v̂ₜ + ε)
All squared operations and divisions above are coordinatewise. For a constant first gradient g, Adam’s first corrected estimates are g and g². Its resulting first step is approximately −η·sign(g) when g is not tiny relative to ε.
Hold the gradients fixed. Inspect the optimizer’s memory.
Every method receives the selected gradient sequence, starts at θ = 2, and uses η = 0.1 with zero initial state. These supplied gradients isolate the update arithmetic; they are not recomputed from a loss surface.
| t | gₜ | Direction state | Squared-gradient state | Update Δθ | θ after update |
|---|---|---|---|---|---|
| 1 | -3.0000 | -3.0000 | — | 0.3000 | 2.3000 |
| 2 | -2.0000 | -4.7000 | — | 0.4700 | 2.7700 |
| 3 | -1.0000 | -5.2300 | — | 0.5230 | 3.2930 |
Momentum uses vₜ = 0.9vₜ₋₁ + gₜ. RMSProp uses α = 0.9 with no momentum. Adam uses β₁ = 0.9, β₂ = 0.999 and bias correction. Both adaptive denominators use √state + 10⁻⁸, matching the PyTorch convention. The lecture’s RMSProp shorthand places ε inside the root; the rounded three-step trace is unchanged here.
Implementation references: PyTorch SGD, RMSprop, and the Adam paper.
Regularizers change different mechanisms.
Dropout, weight decay, and early stopping can all influence generalization, but they do different things. Use them to test a hypothesis about the training problem, rather than treating them as interchangeable switches.
| Method | What changes | What to check |
|---|---|---|
| Dropout | Activations are randomly suppressed during training; inverted dropout rescales survivors. | Use train() for training and eval() for evaluation. Dropout does not directly repair every optimization failure. |
| Weight decay | Parameter magnitudes shrink during updates. | Check whether shrinkage is coupled to the gradient or applied separately. Consider which parameters should receive decay. |
| Early stopping | Validation behavior selects a training duration and checkpoint. | Restore the selected weights and keep test data outside the stopping decision. |
Why AdamW is not simply “Adam plus L2”
With the convention Ltotal = Ldata + (λ/2)‖w‖², the penalty contributes λw to the gradient. For plain SGD without momentum, its update equals a shrinkage term plus the ordinary data-gradient update. With Adam, that added gradient also changes the moment estimates and adaptive rescaling.
wnew = w − η(gdata + λw) = (1 − ηλ)w − ηgdata
AdamW:
wnew = (1 − ηλ)w + adaptive update computed from gdata
AdamW decouples shrinkage from the adaptive update. A learning-rate schedule also changes the amount of shrinkage per step through ηλ. Decay strengths therefore need tuning within the actual optimizer and schedule configuration.
The same decay coefficient can produce different updates.
Start at w = 2 with zero optimizer state and η = 0.1. Compare a first Adam step on Ldata + (λ/2)w² with a first AdamW step using decoupled decay λ.
Decoupled: wnew = (1 − ηλ)w + Adam update from gdata
With zero data gradient, AdamW still shrinks w when λ > 0. With λ = 0 the two computations agree. The lesson uses λ/2 in the penalty so its derivative is λw; the slides’ λw² convention instead gives 2λw. Equal numeric settings do not imply equal regularization across algorithms.
Read the decoupled weight decay paper and PyTorch AdamW documentation.
Plan how the response changes over time.
A schedule changes the learning rate used by later optimizer steps. Step decay encodes milestones, exponential decay reduces the rate proportionally, and cosine decay moves smoothly toward a lower endpoint. Warmup increases the rate during the beginning of a run before another schedule takes over.
Reduce-on-plateau instead responds to an observed metric. A plateau alone does not prove that a rate reduction is the right fix: inspect the data, objective, and training behavior too. Schedules do not replace the optimizer or calculate gradients.
Choose how the learning rate changes over time.
These are explicit example schedules over 40 epochs, with peak rate 0.1. The plot shows the rate used for each epoch; it does not simulate training or guarantee that a schedule will work better.
Reduce-on-plateau differs: its drops depend on an observed metric, so an epoch-only plot cannot determine its shape. In PyTorch, call most schedulers after the optimizer’s update at the intended frequency; ReduceLROnPlateau receives the monitored metric.
API details and stepping order: PyTorch learning-rate scheduling.
Optimization and generalization ask different questions.
Optimization asks whether the training procedure improves its objective. Generalization asks whether performance transfers to relevant unseen data. A flexible model can lower training loss by fitting real structure, noise, or collection artifacts; the training curve alone cannot distinguish those explanations.
| Observed pattern | Possible causes | A useful next check |
|---|---|---|
| Both losses stay high | Insufficient features or capacity, failed optimization, or a data/objective problem | Try overfitting a tiny clean batch; inspect inputs, labels, gradients, and the output/loss pairing. |
| Training improves; validation worsens | Overfitting or distribution mismatch; possibly inconsistent evaluation | Check the split and train/eval modes, then compare regularization or stopping choices using validation. |
| Loss is erratic or non-finite | Unstable updates, excessive step size, numerical problems, or pipeline issues | Inspect finite values and gradient norms; change one factor at a time. |
| Progress is very slow | A small learning rate, poor conditioning, or weak signal | Compare a short controlled rate sweep and optimizer trajectories. |
| Training and validation both improve | A useful fit on these development samples | Confirm with the reserved final evaluation and report sampling uncertainty. |
Bias describes systematic error across possible training samples; variance describes sensitivity to which sample was used. Capacity can affect both, but a single pair of loss curves is not a direct measurement of that decomposition. A more expressive model can still perform poorly because its optimization failed.
Early stopping selects a checkpoint.
At each epoch, evaluate a validation metric. Save a snapshot when it improves according to the chosen rule, reset the waiting counter, and otherwise increment the counter. Stop when patience is exhausted, then restore the selected snapshot.
A snapshot must not be a mutable reference that changes as training continues. In PyTorch, copy the state dictionary when retaining it in memory. Saving model state is sufficient for this inference comparison; exact training resumption requires more state.
The last epoch is not necessarily the model to keep.
These curves are invented to illustrate checkpoint selection. Improvement means a strictly lower validation loss. Stop after the selected number of consecutive non-improving epochs and restore the best checkpoint seen so far.
A short patience may stop before a later recovery. Tune patience using development data. The checkpoint here means model weights; exact training resumption also requires optimizer, scheduler, and relevant random-generator state.
Inspect only the epochs this run observed
| Epoch | Training loss | Validation loss | Checkpoint |
|---|---|---|---|
| 1 | 0.8650 | 0.9000 | — |
| 2 | 0.6792 | 0.7000 | — |
| 3 | 0.5345 | 0.5600 | — |
| 4 | 0.4218 | 0.5000 | — |
| 5 | 0.3340 | 0.4800 | — |
| 6 | 0.2657 | 0.4900 | — |
| 7 | 0.2124 | 0.5000 | — |
| 8 | 0.1710 | 0.4400 | — |
| 9 | 0.1387 | 0.4200 | Retained |
| 10 | 0.1135 | 0.4300 | — |
| 11 | 0.0940 | 0.4400 | — |
| 12 | 0.0787 | 0.4600 | — |
Checkpoint handling: PyTorch’s saving and loading guide.
Compare configured methods, not arbitrary defaults.
| Keep comparable | Tune using validation | Report |
|---|---|---|
| Task, split, input representation, architecture, loss, and initialization policy | Learning rate and optimizer-specific state settings | Training and validation trajectories |
| Seed policy, batch-order policy, stopping rules, and a declared resource budget | Weight decay and schedule choices, with an explicit tuning budget | Epochs, update counts or time, variation across seeds, and the final held-out result |
The example below compares momentum SGD and AdamW, with two learning rates per method and two seeds. It uses the lecture’s 20 → 64 → 1 architecture with ReLU and dropout. Each seed starts from the same initial parameters and batch-order generator across configurations.
The data and split are fixed, and normalization statistics come only from training rows. Both methods start with zero weight decay so a later decay experiment can be explicit. Training and validation curves are measured in eval mode with dropout disabled, making their loss definitions comparable.
Choose a configuration by mean validation loss across the two seeds. The first seed is declared in advance for the final test report, so the script does not choose whichever seed happened to score best. This small study illustrates a procedure; it cannot establish a universal optimizer ranking.
RUN LOCALLY · PYTHON / PYTORCHCPU · NO DATA DOWNLOADpython -m venv .venv
# Activate the environment for your operating system.
python -m pip install torch
python dl-week-3.py
Download the complete Python example ↓Read the complete training and evaluation script
"""DL Week 3: Optimization and Generalization.
An independent teaching example accompanying the Algorithms II Week 4 material.
Run with: python -m pip install torch
python dl-week-3.py
Creates synthetic data locally; writes learning curves, a comparison summary,
and an inference checkpoint in the current directory.
"""
import copy
import csv
import math
import statistics
import time
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
torch.set_num_threads(1)
SEEDS = (17, 29)
MAX_EPOCHS = 50
PATIENCE = 6
BATCH_SIZE = 32
# Start without decay to study the optimizer/rate choices. Investigate
# regularization separately; equal decay numbers have different meanings
# for momentum SGD's coupled penalty and AdamW's decoupled shrinkage.
WEIGHT_DECAY = 0.0
CONFIGS = (
("momentum", 0.01), ("momentum", 0.05),
("adamw", 0.001), ("adamw", 0.01),
)
# The data generator and split are independent of each model's seed.
data_rng = torch.Generator().manual_seed(42)
X = torch.randn(600, 20, generator=data_rng)
signal = (1.4 * X[:, 0] - 0.9 * X[:, 1]
+ 0.6 * X[:, 2] * X[:, 3]
+ 0.5 * torch.randn(600, generator=data_rng))
y = (signal > 0).float().reshape(-1, 1)
indices = torch.randperm(len(X), generator=data_rng)
train_idx, val_idx, test_idx = indices[:360], indices[360:480], indices[480:]
X_train, y_train = X[train_idx], y[train_idx]
X_val, y_val = X[val_idx], y[val_idx]
X_test, y_test = X[test_idx], y[test_idx]
assert not set(train_idx.tolist()) & set(test_idx.tolist())
assert not set(val_idx.tolist()) & set(test_idx.tolist())
# Fit input statistics on training rows only.
train_mean = X_train.mean(dim=0)
train_std = X_train.std(dim=0, unbiased=False).clamp_min(1e-6)
class Classifier(nn.Module):
def __init__(self):
super().__init__()
# Stored with the weights so inference accepts raw inputs.
self.register_buffer("mean", train_mean.clone())
self.register_buffer("std", train_std.clone())
self.layers = nn.Sequential(
nn.Linear(20, 64),
nn.ReLU(),
nn.Dropout(p=0.25),
nn.Linear(64, 1),
)
def forward(self, features):
return self.layers((features - self.mean) / self.std)
loss_fn = nn.BCEWithLogitsLoss()
def score(model, features, labels):
model.eval() # Disable dropout during both train-set and validation scoring.
with torch.inference_mode():
logits = model(features)
loss = float(loss_fn(logits, labels))
accuracy = float(((logits >= 0) == labels.bool()).float().mean())
return loss, accuracy
curves = []
def fit_run(name, rate, seed):
torch.manual_seed(seed)
model = Classifier()
# Separate generator: same initial batch order for a given seed/config.
loader = DataLoader(
TensorDataset(X_train, y_train), batch_size=BATCH_SIZE, shuffle=True,
generator=torch.Generator().manual_seed(seed + 1000),
num_workers=0,
)
if name == "momentum":
optimizer = torch.optim.SGD(
model.parameters(), lr=rate, momentum=0.9,
weight_decay=WEIGHT_DECAY,
)
else:
optimizer = torch.optim.AdamW(
model.parameters(), lr=rate, weight_decay=WEIGHT_DECAY,
)
best_loss, best_epoch, waiting, best_state = math.inf, 0, 0, None
started = time.perf_counter()
for epoch in range(1, MAX_EPOCHS + 1):
model.train() # score() switches to eval, so reset every epoch.
for features, labels in loader:
optimizer.zero_grad(set_to_none=True)
loss = loss_fn(model(features), labels)
if not torch.isfinite(loss):
raise RuntimeError("Non-finite training loss; inspect this run.")
loss.backward()
optimizer.step()
# Both curves use eval mode and the same loss definition.
train_loss, _ = score(model, X_train, y_train)
val_loss, val_accuracy = score(model, X_val, y_val)
if not math.isfinite(val_loss):
raise RuntimeError("Non-finite validation loss.")
curves.append({
"optimizer": name, "lr": rate, "seed": seed, "epoch": epoch,
"train_loss_eval_mode": train_loss,
"validation_loss": val_loss, "validation_accuracy": val_accuracy,
})
if val_loss < best_loss:
best_loss, best_epoch, waiting = val_loss, epoch, 0
# A reference to state_dict() would keep changing during training.
best_state = copy.deepcopy(model.state_dict())
else:
waiting += 1
if waiting >= PATIENCE:
break
elapsed = time.perf_counter() - started
assert best_state is not None
model.load_state_dict(best_state)
restored_loss, restored_accuracy = score(model, X_val, y_val)
assert math.isclose(restored_loss, best_loss, abs_tol=1e-6)
result = {
"optimizer": name, "lr": rate, "seed": seed,
"best_epoch": best_epoch, "epochs_run": epoch,
"validation_loss": restored_loss,
"validation_accuracy": restored_accuracy, "seconds": elapsed,
}
print(result)
return model, result
runs = {}
summaries = []
for name, rate in CONFIGS:
for seed in SEEDS:
model, summary = fit_run(name, rate, seed)
runs[(name, rate, seed)] = model
summaries.append(summary)
# Choose the configuration using mean validation loss across the fixed seeds.
# Do not choose a favorable seed from its score.
mean_validation = {
(name, rate): statistics.mean(
row["validation_loss"] for row in summaries
if row["optimizer"] == name and row["lr"] == rate
)
for name, rate in CONFIGS
}
selected = min(mean_validation, key=mean_validation.get)
print("Mean validation loss by configuration:", mean_validation)
print("Selected configuration:", selected)
# Prespecify the first seed's retained model for the single final test report.
# This is a small teaching study, not a robust ranking of optimizer families.
final_model = runs[(*selected, SEEDS[0])]
test_loss, test_accuracy = score(final_model, X_test, y_test)
print("Final held-out test loss / accuracy:", test_loss, test_accuracy)
for filename, rows in (
("dl-week-3-curves.csv", curves),
("dl-week-3-summary.csv", summaries),
):
with open(filename, "w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
checkpoint = {
"state_dict": final_model.state_dict(),
"optimizer_name": selected[0], "learning_rate": selected[1],
"seed": SEEDS[0], "feature_order": [f"x{i}" for i in range(20)],
}
torch.save(checkpoint, "dl-week-3.pt")
reloaded = Classifier()
reloaded.load_state_dict(
torch.load("dl-week-3.pt", weights_only=True)["state_dict"]
)
final_model.eval()
reloaded.eval()
with torch.inference_mode():
torch.testing.assert_close(final_model(X_val), reloaded(X_val))
print("Saved inference checkpoint; reloaded validation predictions match.")
print("Versions: PyTorch", torch.__version__)
print("Open dl-week-3-curves.csv in Excel; group by optimizer, lr, and seed.")
# For exact training resumption, also save optimizer/scheduler state, epoch,
# random-generator state, and any other state required by the input pipeline.
Inspect the outputs
dl-week-3-curves.csv: open in Excel, filter to one optimizer/rate/seed, and plot epoch against the two loss columns.dl-week-3-summary.csv: compare retained validation performance, selected epochs, total epochs, and measured runtime.dl-week-3.pt: model weights and fitted input statistics for inference. The script reloads it and checks that predictions match.- The final test loss and accuracy appear only after configuration selection. Keep that result out of subsequent tuning decisions.
Package versions and hardware can affect timing and exact numerical results. The script reports its PyTorch version. The examples and browser experiments are study companions to the supplied lecture, not measured results from its slides.
Make the mechanism visible.
- Enumerate the noise. Set θ = 2 in the batch demo. Explain why the full gradient is zero while individual minibatches need not be. Confirm that all equally likely batch estimates average to zero.
- Trace the lecture arithmetic. With gradients −3, −2, −1, η = 0.1, and β = 0.9, calculate the three momentum velocities and parameter values before checking the table. Repeat the squared-gradient trace for RMSProp with gradients 4, 2, 1.
- Find the stability boundary. Use SGD with curvature 20. Compare rates 0.08, 0.10, and 0.12. Explain the behavior of the steep coordinate from its multiplier, 1 − ηc.
- Separate two decay mechanisms. Set the data gradient to zero. Compare the first Adam + L2 and AdamW steps as λ changes, keeping the same rate. State which formula convention you used.
- Write a comparison before running it. Choose an experiment with a fixed split and a stated tuning budget. Use the CSV curves to report what happened, including a result that challenges your initial expectation.
Week 3 interview questions
Try answering aloud before opening the explanation.
01What does the optimizer receive from backpropagation?
Gradients of the chosen objective with respect to parameters. The optimizer combines these with its learning rate, state, and update rules. Backpropagation itself does not choose the training strategy.
02Is every minibatch gradient an unbiased estimate?
For a fixed parameter value and uniformly sampled examples, the average of per-example gradients is an unbiased estimate of the empirical mean gradient. Different sampling or weighting schemes can change that property. Batch-dependent operations, such as BatchNorm, also complicate the simple per-example decomposition.
03Why does curvature affect the learning rate?
For a positive-definite quadratic with largest curvature λmax, plain gradient descent converges when 0 < η < 2/λmax. The steepest direction limits the step size, which can make progress slow in flatter directions. A nonconvex network is more complicated than this local illustration.
04How can identical current gradients give different momentum updates?
The stored velocity depends on previous gradients. With v = βv_previous + g, two runs with the same g but different v_previous produce different updates. Identical gradients do not imply identical optimizer state.
05How do RMSProp and Adam differ?
RMSProp rescales a coordinate using a moving average of squared gradients. Adam also maintains a moving average of gradients and corrects the zero-initialization bias in its first and second moment estimates. Exact defaults and options matter.
06Why does Adam need bias correction?
Starting its moving averages at zero reduces their early magnitude. Under a stationary moment model, dividing by 1 − β^t corrects that initialization bias. It does not remove dataset bias or guarantee a good update.
07Is AdamW simply Adam with an L2 term added to the loss?
No. A loss penalty contributes to the gradient and enters Adam’s adaptive moment calculations. AdamW applies parameter shrinkage separately. For plain SGD without momentum, a suitably scaled L2 penalty and decay can give the same update; that equivalence does not carry over unchanged to adaptive methods.
08Does a learning-rate schedule calculate gradients?
No. It changes the rate used by future optimizer steps. Some schedules depend on a training-time index; reduce-on-plateau depends on a monitored metric. The optimizer and gradient calculation still have their own jobs.
09Do low training loss and high validation loss prove overfitting?
They suggest a generalization problem, but distribution mismatch, a flawed split, or mismatched evaluation behavior may also contribute. Inspect the pipeline and compare controlled changes before choosing a remedy.
10Which model should early stopping return?
The checkpoint with the best validation behavior according to the specified improvement rule, not necessarily the final logged epoch. Validation has selected training duration. The final test set must stay outside that selection.
11Does model.eval() disable autograd?
No. It changes modules such as Dropout and BatchNorm into evaluation behavior. Use no_grad() or inference_mode() separately when gradients are unnecessary.
12What makes an optimizer comparison fair?
Use a common task, split, architecture, loss, and initialization policy; give each method an appropriate validation-based tuning budget. Report trajectories, cost, stopping rules, and variation across seeds. Evaluate the chosen procedure on held-out test data after selection.
Research connections
Kingma & Ba · 2014
Adam combines adaptive first- and second-moment estimates with bias correction.
Adam: A Method for Stochastic Optimization ↗Loshchilov & Hutter · 2017 preprint
Decoupled weight decay distinguishes the parameter-shrinkage step from adaptive loss-gradient updates.
Decoupled Weight Decay Regularization ↗The next week introduces locality, shared patterns, and convolutional neural networks, matching the lecture’s bridge to course Week 5.
Preview Week 4: Convolutional Neural Networks →