Week 12: Training Deep Networks with PyTorch

Week 11's hand-rolled NumPy network proved you understand what's happening underneath. Nobody hand-writes backpropagation for real work, though — this week hands that mechanical part to PyTorch's autograd, and spends the time you saved on the things that actually separate a network that trains from one that doesn't: the right optimizer, the right regularization, and reading a training curve correctly.

Phase 4 of 8 Week 12 of 26 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write a complete PyTorch training loop from tensors to a trained model
  • Choose an optimizer and apply at least two regularization techniques correctly
  • Diagnose a network that "isn't learning" from its training/validation curves

1. Tensors, Autograd & the Training Loop

A PyTorch tensor is NumPy's ndarray with one crucial addition: if you set requires_grad=True, PyTorch records every operation performed on it, building a graph it can later walk backward through to compute gradients automatically — exactly the backpropagation you derived by hand in Week 11, generalized to networks of any size.

training_loop.py
import torch
import torch.nn as nn

model = nn.Sequential(
    nn.Linear(20, 64), nn.ReLU(),
    nn.Linear(64, 32), nn.ReLU(),
    nn.Linear(32, 1), nn.Sigmoid(),
)

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

for epoch in range(50):
    optimizer.zero_grad()               # clear gradients from the last step
    y_pred = model(X_train_tensor)      # forward pass
    loss = loss_fn(y_pred, y_train_tensor)
    loss.backward()                      # autograd computes every gradient
    optimizer.step()                     # apply the update (Week 11's "W -= lr * dW", generalized)

optimizer.zero_grad() exists because PyTorch accumulates gradients by default rather than overwriting them — forgetting this call is one of the most common bugs in a first PyTorch training loop, silently summing gradients across steps instead of computing each step's fresh.

2. Optimizers: SGD, Momentum & Adam

Week 11's gradient descent update — subtract the gradient, scaled by a learning rate — is plain SGD. In practice, three refinements almost always train faster and more reliably:

  • SGD with momentum — accumulates a running average of past gradients, so updates build speed in a consistent direction and dampen oscillation across noisy ones.
  • Adam — maintains a per-parameter adaptive learning rate based on both the gradient's recent average and its recent variance, making it far more forgiving of a poorly-chosen global learning rate.
optimizers.py
import torch.optim as optim

sgd = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
adam = optim.Adam(model.parameters(), lr=1e-3, betas=(0.9, 0.999))

Adam is the practical default for most projects — it converges quickly with little tuning. Plain SGD with momentum still shows up in settings (some vision and large language model training runs) where its slightly different generalization behavior is worth the extra tuning effort, but as a rule: reach for Adam first, and only revisit that choice if you have a specific reason to.

3. Regularization: Dropout, Weight Decay & Early Stopping

These are Week 5's overfitting fixes, now made concrete for neural networks specifically.

regularized_model.py
model = nn.Sequential(
    nn.Linear(20, 64), nn.ReLU(), nn.Dropout(p=0.3),
    nn.Linear(64, 32), nn.ReLU(), nn.Dropout(p=0.3),
    nn.Linear(32, 1), nn.Sigmoid(),
)

# Weight decay: L2 penalty added directly by the optimizer
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)

Dropout randomly zeroes out a fraction of neurons on every training step, forcing the network to not rely too heavily on any single neuron — a form of built-in ensembling, conceptually related to the bagging from Week 9. Weight decay penalizes large weight values directly in the loss, discouraging the network from fitting noise with extreme weights. Early stopping — stopping training the moment validation loss stops improving, exactly as with early_stopping_rounds in Week 9's gradient boosting — prevents a network from continuing to memorize the training set after it has already found the useful pattern.

Don't forget model.eval()

Dropout should be active during training but disabled during evaluation, so predictions are deterministic and use the full network. Calling model.eval() before validation/inference (and model.train() before resuming training) switches this automatically — a very easy step to forget.

4. Batch Normalization & Learning Rate Schedules

Batch normalization normalizes each layer's activations (subtracting the batch mean, dividing by the batch standard deviation) before the next layer sees them — the same standardization idea from Week 4, applied between layers rather than only at the input. This keeps activations in a consistent, well-behaved range throughout training, which generally allows higher learning rates and faster convergence.

batchnorm_and_schedule.py
model = nn.Sequential(
    nn.Linear(20, 64), nn.BatchNorm1d(64), nn.ReLU(),
    nn.Linear(64, 32), nn.BatchNorm1d(32), nn.ReLU(),
    nn.Linear(32, 1), nn.Sigmoid(),
)

optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)

for epoch in range(50):
    # ... training loop ...
    scheduler.step()   # halve the learning rate every 10 epochs

A learning rate schedule lowers the learning rate over the course of training — large steps early to make fast progress, smaller steps later to settle precisely into a good minimum instead of oscillating around it. This is a direct answer to Week 11's tension between "too large" and "too small" learning rates: rather than picking one fixed value, start large and shrink it over time.

5. Reading Training/Validation Curves to Diagnose Problems

This is Week 5's learning curve, extended over training epochs rather than dataset size, and it's the single most useful debugging tool for a neural network.

  • Both losses stay high and flat — underfitting; the model is too small, or the learning rate is badly chosen. Try a larger network or a different learning rate before anything else.
  • Training loss keeps dropping, validation loss stops or rises — classic overfitting; apply Section 3's tools (dropout, weight decay, early stopping).
  • Both losses drop together, smoothly — healthy training; keep going, or consider whether you've plateaued and could benefit from Section 4's schedule.
  • Loss is NaN or explodes — almost always a learning-rate-too-high or unstabilized-activation problem; lower the learning rate and check for batch normalization.

6. Hands-on Exercise

Hands-on

Train, overfit on purpose, then fix it with regularization

You're given a small, noisy synthetic classification dataset — deliberately small enough that an unregularized network will overfit visibly.

starter code
from sklearn.datasets import make_classification
import torch

X, y = make_classification(
    n_samples=300, n_features=20, n_informative=6,
    n_redundant=8, flip_y=0.1, random_state=42,
)

Requirements:

  1. Split into train/validation/test (Week 5), convert to tensors, and train an unregularized 3-layer network for 200 epochs with Adam.
  2. Plot training and validation loss per epoch, and confirm a visible overfitting gap (Section 5).
  3. Add dropout and weight decay, retrain from scratch, and confirm the gap shrinks.
  4. Add early stopping (track the best validation loss and stop if it hasn't improved in 15 epochs), and report at which epoch it stopped.
  5. Compare final test-set performance between the unregularized and regularized models.
Hint

Remember model.eval() before computing validation loss each epoch, and model.train() before the next training step — forgetting this with dropout in the model will make your validation loss look artificially noisy or wrong.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why must you call optimizer.zero_grad() at the start of every training step?

PyTorch accumulates gradients into .grad by default rather than overwriting them on each .backward() call. Without clearing them first, each step's gradients would be summed with every previous step's, corrupting the update — zero_grad() ensures each step computes a fresh gradient from that step's data alone.

Q2

Why is Adam generally more forgiving of a poorly-chosen learning rate than plain SGD?

Adam maintains a per-parameter adaptive learning rate, scaled by that parameter's recent gradient magnitude and variance — parameters with large, noisy gradients automatically get smaller effective steps, and vice versa. Plain SGD applies one fixed learning rate uniformly to every parameter, so a global rate that's poorly chosen affects everything equally.

Q3

Why should dropout be disabled during evaluation, and how do you disable it in PyTorch?

Dropout randomly zeroes neurons to prevent overfitting during training, but this randomness would make evaluation/inference non-deterministic and use only part of the network — you want the full, stable network making predictions at evaluation time. Calling model.eval() switches dropout (and batch norm) into evaluation mode automatically.

Q4

Training loss keeps falling but validation loss starts rising after epoch 20. What should you do?

This is the classic overfitting signature from Section 5 — apply regularization (dropout, weight decay) or use early stopping to stop training around epoch 20, before validation performance degrades further. Training for more epochs at this point only makes the network memorize the training set more, not generalize better.