Ch 2 — Training Deep Networks¶
Learning Objectives¶
By the end of this chapter you will be able to:
- Explain the mechanics of SGD, Momentum, RMSProp, and Adam, and select the appropriate optimiser for a given task.
- Implement and compare learning-rate schedules including step decay, cosine annealing, linear warmup, and cyclic LR.
- Apply batch normalisation and layer normalisation correctly and explain why each helps training.
- Use dropout, L1/L2 regularisation, early stopping, and gradient clipping to control overfitting.
- Write a production-quality PyTorch training loop with mixed-precision, gradient accumulation, and model checkpointing.
1. Optimisers¶
1.1 Stochastic Gradient Descent (SGD)¶
The simplest update rule. At step \(t\), given gradient \(g_t = \nabla_\theta \mathcal{L}\):
Vanilla SGD is unbiased and memory-efficient but slow to converge — each noisy gradient step can point in an arbitrary direction.
1.2 SGD with Momentum¶
Exponentially weighted moving average of past gradients (the velocity \(v_t\)):
Typical \(\mu = 0.9\). Momentum damps oscillations in high-curvature directions and accelerates movement in consistent directions, effectively increasing the step size along the loss valley.
Nesterov Momentum (look-ahead variant):
1.3 RMSProp¶
Adapts the learning rate per-parameter by dividing by a moving average of squared gradients:
Typical \(\beta = 0.99\), \(\epsilon = 10^{-8}\). Solves the problem of Adagrad's monotonically decreasing learning rate by using an exponential rather than cumulative average.
1.4 Adam (Adaptive Moment Estimation)¶
Adam combines Momentum (first moment) with RMSProp (second moment):
First moment (mean of gradients):
Second moment (uncentred variance of gradients):
Bias correction (initial moments are biased toward zero because \(m_0 = v_0 = 0\)):
Parameter update:
Default hyperparameters: \(\beta_1 = 0.9\), \(\beta_2 = 0.999\), \(\epsilon = 10^{-8}\), \(\eta = 3 \times 10^{-4}\).
AdamW
The original Adam applies weight decay inside the adaptive term, which is incorrect. AdamW (Loshchilov & Hutter, 2019) decouples weight decay from the gradient update: $\(\theta_t = \theta_{t-1} - \eta \left(\frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} + \lambda \theta_{t-1}\right)\)$ AdamW is now the standard for Transformer training.
1.5 Optimiser Comparison¶
| Optimiser | Memory | Adaptive LR | Notes |
|---|---|---|---|
| SGD | \(O(d)\) | No | Best final performance with tuning; used for CV |
| SGD + Momentum | \(O(d)\) | No | Default for ResNet-style CV training |
| RMSProp | \(O(d)\) | Per-param | Good for RNNs |
| Adam | \(O(3d)\) | Per-param | Default for NLP / Transformers |
| AdamW | \(O(3d)\) | Per-param | Preferred over Adam; correct weight decay |
2. Learning Rate Schedulers¶
A fixed learning rate is rarely optimal. Start too high and training diverges; keep it too high and you cannot settle into a sharp minimum.
2.1 Step Decay¶
Multiply \(\eta\) by a factor \(\gamma < 1\) every \(k\) epochs:
Simple and interpretable. Common: \(\gamma = 0.1\) every 30 epochs.
2.2 Cosine Annealing¶
Smoothly decreases the learning rate following a half-cosine from \(\eta_{\max}\) to \(\eta_{\min}\):
Avoids the abrupt drops of step decay. Widely used with restarts (SGDR).
2.3 Linear Warmup¶
Many Transformer training recipes warm up the learning rate linearly for \(T_w\) steps:
Warmup is critical for Adam because the first-moment estimates are unreliable early in training, and a high initial learning rate can cause instability.
2.4 Cyclic Learning Rate¶
Oscillates between \(\eta_{\min}\) and \(\eta_{\max}\) on a triangular wave. High learning rates act as simulated annealing — temporarily escaping local minima. Triangular schedule:
where \(\text{frac}(x) = x - \lfloor x \rfloor\).
Practical Recommendation
For most Transformer fine-tuning: linear warmup for 5% of total steps, then cosine decay to 0. For CNNs on ImageNet: step decay (×0.1 at epochs 30, 60, 90) or cosine.
3. Batch Normalisation¶
3.1 Motivation¶
Internal covariate shift: the distribution of each layer's inputs changes as earlier layers' parameters update, forcing downstream layers to constantly readapt. Batch Normalisation (Ioffe & Szegedy, 2015) normalises each feature within a mini-batch.
3.2 Formula¶
For each feature \(j\) over a mini-batch \(\mathcal{B} = \{x_1, \ldots, x_N\}\):
where \(\gamma_j\) (scale) and \(\beta_j\) (shift) are learnable parameters that allow the network to undo the normalisation if beneficial.
3.3 Training vs Inference¶
During training: use mini-batch statistics \(\mu_\mathcal{B}\), \(\sigma^2_\mathcal{B}\).
During inference: use running (exponential) averages \(\mu_{\text{run}}\), \(\sigma^2_{\text{run}}\) accumulated during training. This makes the output deterministic and independent of batch size.
3.4 Why BatchNorm Helps¶
- Reduces sensitivity to weight initialisation.
- Acts as a form of regularisation (the normalisation noise is batch-dependent).
- Allows higher learning rates by smoothing the loss landscape.
- Speeds up convergence by roughly 2-10× in practice.
4. Layer Normalisation vs Batch Normalisation¶
4.1 Layer Normalisation¶
Normalises across the feature dimension for each example independently:
No batch dimension involved — statistics are computed per sample.
4.2 When to Use Each¶
| Criterion | Batch Norm | Layer Norm |
|---|---|---|
| Architecture | CNNs | Transformers, RNNs |
| Batch size dependency | Requires large batches (≥16) | Independent of batch size |
| Works at inference | Yes (running stats) | Yes (per-sample) |
| Sequence tasks | Problematic (variable length) | Natural |
| Image tasks | Very effective | Less common |
BatchNorm with Small Batches
With batch sizes < 8, batch statistics are too noisy. Use Group Norm or Layer Norm instead. This is a common source of bugs when porting models to inference with batch size 1.
5. Dropout¶
5.1 Mechanism¶
During training, each neuron is independently zeroed out with probability \(p\) (typically \(p = 0.1\)–\(0.5\)):
The \(1/(1-p)\) scaling is inverted dropout — it ensures the expected value of \(\tilde{h}\) equals \(h\), so no scaling change is needed at inference.
5.2 Training vs Inference¶
- Training: Apply dropout mask, scale by \(1/(1-p)\).
- Inference: Use the full network (dropout disabled). In PyTorch,
model.eval()automatically disables dropout.
5.3 Regularisation Effect¶
Dropout forces the network to not rely on any single neuron — each sub-network (defined by the dropout mask) must independently solve the task. This is equivalent to training an exponential ensemble of sub-networks.
6. L1 and L2 Regularisation¶
6.1 L2 Regularisation (Weight Decay)¶
Adds \(\frac{\lambda}{2}\|W\|_F^2\) to the loss:
Gradient: \(\nabla_W \mathcal{L}_{\text{reg}} = \nabla_W \mathcal{L} + \lambda W\)
This shrinks weights toward zero. In SGD update: \(W \leftarrow (1 - \eta\lambda)W - \eta\nabla_W \mathcal{L}\).
6.2 L1 Regularisation (Sparsity)¶
Adds \(\lambda \|W\|_1\) to the loss. The subgradient is \(\lambda \operatorname{sign}(W)\), which pushes weights exactly to zero — producing sparse solutions. Rarely used in deep learning (not differentiable at zero).
6.3 In PyTorch¶
L2 via optimiser:
L1 manually:
7. Early Stopping and Checkpointing¶
Early stopping monitors a held-out validation metric. If the metric does not improve for patience epochs, training stops. This prevents overfitting without needing to specify the number of epochs upfront.
Model checkpointing saves the model state at each new validation best. The saved checkpoint is the final model — not the weights at the last epoch (which may have overfit).
best_val_loss = float("inf")
patience_counter = 0
PATIENCE = 10
for epoch in range(max_epochs):
train(...)
val_loss = validate(...)
if val_loss < best_val_loss:
best_val_loss = val_loss
patience_counter = 0
torch.save(model.state_dict(), "best_model.pt")
else:
patience_counter += 1
if patience_counter >= PATIENCE:
print("Early stopping triggered.")
break
8. Mixed Precision Training¶
8.1 Why Mixed Precision?¶
Modern GPUs have specialised tensor cores that run FP16 operations at 2–8× the throughput of FP32. Mixed precision uses FP16 for the bulk of compute (forward pass, backward pass) while keeping master weights in FP32 for numerical stability.
8.2 FP16 vs BF16¶
| Format | Bits | Exponent | Mantissa | Range | Precision |
|---|---|---|---|---|---|
| FP32 | 32 | 8 | 23 | ±3.4×10³⁸ | High |
| FP16 | 16 | 5 | 10 | ±65504 | Medium |
| BF16 | 16 | 8 | 7 | ±3.4×10³⁸ | Low |
BF16 has the same dynamic range as FP32 (wide exponent) but lower mantissa precision. It is less prone to overflow than FP16 and is the recommended format for Transformer training on Ampere+ GPUs and TPUs.
8.3 Loss Scaling (for FP16)¶
FP16 cannot represent gradients smaller than \(\sim 6 \times 10^{-8}\). Gradients in this range underflow to zero. Loss scaling multiplies the loss by a large constant \(S\) before the backward pass, then divides the gradients by \(S\) before the update:
Dynamic loss scaling (PyTorch GradScaler) automatically adjusts \(S\) — increasing when no overflow is detected, decreasing otherwise.
9. Gradient Accumulation¶
When GPU memory limits batch size, gradient accumulation simulates a larger effective batch:
Accumulate gradients for \(k\) micro-batches before calling optimiser.step() and optimiser.zero_grad(). This is mathematically equivalent to one step on the concatenated batch, assuming the loss is averaged (not summed) over the batch.
10. Full PyTorch Training Loop¶
"""
Production-quality PyTorch training loop.
Features:
- DataLoader with prefetching
- AdamW optimiser with weight decay
- Cosine annealing scheduler with linear warmup
- Gradient clipping
- Mixed precision training (torch.amp)
- Gradient accumulation
- Model checkpointing with early stopping
- Validation loop
"""
from __future__ import annotations
import math
from pathlib import Path
from typing import Any
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch import Tensor
from torch.cuda.amp import GradScaler, autocast
from torch.optim.lr_scheduler import LambdaLR
from torch.utils.data import DataLoader
# ---------------------------------------------------------------------------
# Scheduler: linear warmup + cosine decay
# ---------------------------------------------------------------------------
def get_cosine_schedule_with_warmup(
optimiser: torch.optim.Optimizer,
num_warmup_steps: int,
num_training_steps: int,
eta_min_ratio: float = 0.0,
) -> LambdaLR:
"""Cosine decay schedule with linear warmup.
Args:
optimiser: The optimiser to schedule.
num_warmup_steps: Number of linear warmup steps.
num_training_steps: Total number of training steps.
eta_min_ratio: Minimum LR as a fraction of peak LR.
Returns:
A LambdaLR scheduler.
"""
def lr_lambda(current_step: int) -> float:
# Warmup phase
if current_step < num_warmup_steps:
return float(current_step) / float(max(1, num_warmup_steps))
# Cosine decay
progress = float(current_step - num_warmup_steps) / float(
max(1, num_training_steps - num_warmup_steps)
)
cosine_decay = 0.5 * (1.0 + math.cos(math.pi * progress))
return eta_min_ratio + (1.0 - eta_min_ratio) * cosine_decay
return LambdaLR(optimiser, lr_lambda)
# ---------------------------------------------------------------------------
# Trainer
# ---------------------------------------------------------------------------
class Trainer:
"""Generic trainer for classification models.
Args:
model: PyTorch model (nn.Module).
train_loader: DataLoader for training data.
val_loader: DataLoader for validation data.
lr: Peak learning rate.
weight_decay: AdamW weight decay coefficient.
max_epochs: Maximum number of training epochs.
grad_clip: Max gradient norm for clipping (0 = disabled).
accumulation_steps: Number of micro-batches to accumulate.
patience: Early stopping patience (epochs).
checkpoint_path: Path to save the best model checkpoint.
use_amp: Whether to use automatic mixed precision.
device: Device to train on.
"""
def __init__(
self,
model: nn.Module,
train_loader: DataLoader,
val_loader: DataLoader,
lr: float = 3e-4,
weight_decay: float = 1e-2,
max_epochs: int = 100,
grad_clip: float = 1.0,
accumulation_steps: int = 1,
patience: int = 10,
checkpoint_path: str | Path = "best_model.pt",
use_amp: bool = True,
device: torch.device | None = None,
) -> None:
self.model = model
self.train_loader = train_loader
self.val_loader = val_loader
self.max_epochs = max_epochs
self.grad_clip = grad_clip
self.accumulation_steps = accumulation_steps
self.patience = patience
self.checkpoint_path = Path(checkpoint_path)
self.device = device or torch.device(
"cuda" if torch.cuda.is_available() else "cpu"
)
self.use_amp = use_amp and self.device.type == "cuda"
self.model.to(self.device)
# Optimiser: separate weight-decay and no-decay param groups
decay_params = [
p for n, p in model.named_parameters()
if p.requires_grad and p.dim() >= 2
]
no_decay_params = [
p for n, p in model.named_parameters()
if p.requires_grad and p.dim() < 2
]
self.optimiser = torch.optim.AdamW(
[
{"params": decay_params, "weight_decay": weight_decay},
{"params": no_decay_params, "weight_decay": 0.0},
],
lr=lr,
)
# Scheduler: warmup for 5% of total steps
total_steps = len(train_loader) * max_epochs // accumulation_steps
warmup_steps = max(1, total_steps // 20)
self.scheduler = get_cosine_schedule_with_warmup(
self.optimiser, warmup_steps, total_steps
)
# Mixed precision
self.scaler = GradScaler(enabled=self.use_amp)
# State
self.best_val_loss = float("inf")
self._patience_counter = 0
# ------------------------------------------------------------------
# One training epoch
# ------------------------------------------------------------------
def _train_epoch(self) -> float:
"""Run one training epoch. Returns mean loss over all batches."""
self.model.train()
total_loss = 0.0
self.optimiser.zero_grad()
for step, (x_batch, y_batch) in enumerate(self.train_loader):
x_batch = x_batch.to(self.device, non_blocking=True)
y_batch = y_batch.to(self.device, non_blocking=True)
with autocast(enabled=self.use_amp):
logits = self.model(x_batch)
loss = F.cross_entropy(logits, y_batch)
# Scale by accumulation steps so effective LR doesn't change
loss = loss / self.accumulation_steps
self.scaler.scale(loss).backward()
if (step + 1) % self.accumulation_steps == 0:
# Unscale before gradient clipping
self.scaler.unscale_(self.optimiser)
if self.grad_clip > 0:
nn.utils.clip_grad_norm_(
self.model.parameters(), self.grad_clip
)
self.scaler.step(self.optimiser)
self.scaler.update()
self.scheduler.step()
self.optimiser.zero_grad()
total_loss += loss.item() * self.accumulation_steps
return total_loss / len(self.train_loader)
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
@torch.no_grad()
def _validate(self) -> tuple[float, float]:
"""Evaluate on validation set. Returns (loss, accuracy)."""
self.model.eval()
total_loss, correct, total = 0.0, 0, 0
for x_batch, y_batch in self.val_loader:
x_batch = x_batch.to(self.device, non_blocking=True)
y_batch = y_batch.to(self.device, non_blocking=True)
with autocast(enabled=self.use_amp):
logits = self.model(x_batch)
loss = F.cross_entropy(logits, y_batch, reduction="sum")
total_loss += loss.item()
correct += (logits.argmax(dim=1) == y_batch).sum().item()
total += y_batch.size(0)
return total_loss / total, correct / total
# ------------------------------------------------------------------
# Checkpointing
# ------------------------------------------------------------------
def _save_checkpoint(self, epoch: int, val_loss: float) -> None:
"""Save model state dict and training metadata."""
checkpoint: dict[str, Any] = {
"epoch": epoch,
"model_state": self.model.state_dict(),
"optimiser_state": self.optimiser.state_dict(),
"scheduler_state": self.scheduler.state_dict(),
"val_loss": val_loss,
}
torch.save(checkpoint, self.checkpoint_path)
def load_best(self) -> None:
"""Restore best checkpoint into the model."""
ckpt = torch.load(self.checkpoint_path, map_location=self.device)
self.model.load_state_dict(ckpt["model_state"])
print(f"Loaded best model from epoch {ckpt['epoch']} "
f"(val_loss={ckpt['val_loss']:.4f})")
# ------------------------------------------------------------------
# Main training loop
# ------------------------------------------------------------------
def fit(self) -> dict[str, list[float]]:
"""Train the model and return loss/accuracy history.
Returns:
Dictionary with keys 'train_loss', 'val_loss', 'val_acc'.
"""
history: dict[str, list[float]] = {
"train_loss": [], "val_loss": [], "val_acc": []
}
for epoch in range(1, self.max_epochs + 1):
train_loss = self._train_epoch()
val_loss, val_acc = self._validate()
history["train_loss"].append(train_loss)
history["val_loss"].append(val_loss)
history["val_acc"].append(val_acc)
lr_now = self.optimiser.param_groups[0]["lr"]
print(
f"Epoch {epoch:4d}/{self.max_epochs} "
f"train_loss={train_loss:.4f} "
f"val_loss={val_loss:.4f} "
f"val_acc={val_acc:.3f} "
f"lr={lr_now:.2e}"
)
# Checkpointing
if val_loss < self.best_val_loss:
self.best_val_loss = val_loss
self._patience_counter = 0
self._save_checkpoint(epoch, val_loss)
print(" --> Saved new best checkpoint.")
else:
self._patience_counter += 1
if self._patience_counter >= self.patience:
print(f"Early stopping at epoch {epoch}.")
break
self.load_best()
return history
Exercises¶
-
Adam convergence. Starting from initial parameters \(\theta_0 = 1.0\), \(m_0 = v_0 = 0\), and using \(\beta_1 = 0.9\), \(\beta_2 = 0.999\), \(\epsilon = 10^{-8}\), \(\eta = 0.001\), and a constant gradient \(g = 0.5\) for 5 steps, compute \(\theta_1\) through \(\theta_5\) by hand. Verify that bias correction prevents premature large steps.
-
Scheduler ablation. Implement a training loop that trains a small MLP on MNIST and compares: no scheduler, step decay (×0.1 at epochs 5, 10), cosine annealing, and linear warmup + cosine. Plot validation accuracy vs epoch for each. Which converges fastest? Which reaches the lowest loss?
-
BatchNorm position. Research Pre-LN vs Post-LN for Transformers. Implement a 3-layer MLP with BatchNorm placed (a) before the activation and (b) after the activation. Train on CIFAR-10 and report whether position matters significantly.
-
Gradient accumulation correctness. Show mathematically that accumulating gradients over \(k\) micro-batches with average loss is equivalent to computing the gradient on the full concatenated batch (hint: linearity of expectation). What breaks if you use sum loss instead of mean?
-
Mixed precision debugging. Deliberately create a scenario where FP16 gradients underflow to zero (e.g., very small learning rates and specific initialisations). Verify using
GradScalerthat loss scaling fixes the issue. Monitor the scaler'sget_scale()value throughout training.
Summary¶
| Component | Purpose | Key Hyperparameter |
|---|---|---|
| SGD + Momentum | Convex / vision training | \(\mu = 0.9\), \(\eta\) tuning |
| Adam / AdamW | Transformer / NLP | \(\beta_1=0.9\), \(\beta_2=0.999\), \(\eta \sim 3\times10^{-4}\) |
| Cosine + Warmup | LR schedule | Warmup = 5% of steps |
| Batch Norm | Normalise per-feature per-batch | \(\epsilon=10^{-5}\) |
| Layer Norm | Normalise per-token per-example | \(\epsilon=10^{-6}\) |
| Dropout | Regularisation | \(p = 0.1\)–\(0.3\) |
| L2 (weight decay) | Prevent large weights | \(\lambda \sim 10^{-2}\)–\(10^{-4}\) |
| Early stopping | Prevent overfitting | Patience = 5–20 |
| Mixed precision | Speed and memory | BF16 preferred on Ampere+ |
| Gradient clipping | Stability | max_norm \(= 1.0\) |
Training deep networks is as much engineering as science. A bug in any one of these components — wrong batch norm statistics at inference, missing zero_grad(), loss not divided by accumulation steps, forgetting model.eval() — can silently produce poor results. The training loop above encapsulates these correctly; use it as a template and adapt as needed.