05 — Training Dynamics¶
Phase 2 · Months 5–6 | Estimated time: 10–14 hours across 2 weeks
Training dynamics is the discipline of understanding why a model converges, plateaus, diverges, or silently produces garbage. Most practitioners know how to call loss.backward(). Fewer know how to diagnose why their training loss hit 0.4 and stopped, or why their model performs well in the training loop but produces nonsense at inference. This module covers the mechanisms behind the tools — because tools misconfigured at the wrong moment don’t fail loudly. They fail quietly.
1. Learning Rate Schedules¶
The learning rate is the single most impactful hyperparameter. Too high: divergence or oscillation. Too low: slow convergence or plateau in a suboptimal local minimum. A fixed learning rate is almost never the right answer — the optimal rate changes as training progresses.
The following table covers the main strategies used in practice as of 2025.
Schedule |
Behavior |
When to Use |
|---|---|---|
Constant |
Fixed LR throughout |
Debugging only; almost never optimal in production |
Step decay |
Multiply LR by γ every N epochs |
Old default; arbitrary step boundaries are a hyperparameter you don’t want to tune |
Cosine annealing |
Smooth decay from LR_max to LR_min following a cosine curve |
Standard for CNNs and anything without a warmup requirement |
Warmup + cosine |
Linear increase for N steps, then cosine decay |
Standard for transformers in 2025; prevents early instability from random init |
1-cycle policy |
LR ramps up then decays; momentum inversely follows |
fast.ai’s contribution; enables training at higher peak LRs; faster convergence |
Why cosine annealing works: The cosine curve provides smooth exploration early (LR still high) and smooth exploitation late (LR has decayed). Unlike step decay, there are no abrupt LR drops that can destabilize training. The smoothness means the loss landscape is being navigated without sudden jumps.
import torch
import torch.optim as optim
model = ... # your model
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# Cosine annealing: decays LR from lr to eta_min over T_max epochs
scheduler = optim.lr_scheduler.CosineAnnealingLR(
optimizer,
T_max=50, # number of epochs to complete one cycle
eta_min=1e-6 # minimum LR at the bottom of the cosine
)
for epoch in range(50):
train_one_epoch(model, ...)
scheduler.step()
print(f"Epoch {epoch}: LR = {scheduler.get_last_lr()[0]:.2e}")
# ── Warmup + Cosine (for transformers) ─────────────────────────────────────
from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR, SequentialLR
warmup = LinearLR(optimizer, start_factor=0.1, end_factor=1.0, total_iters=10)
cosine = CosineAnnealingLR(optimizer, T_max=90, eta_min=1e-6)
scheduler = SequentialLR(optimizer, schedulers=[warmup, cosine], milestones=[10])
2. Weight Initialization¶
Initialization determines the starting point of optimization and has a larger impact than most practitioners expect. A bad initialization can cause activations to explode or vanish before training even begins — meaning the first forward pass already puts you in an unrecoverable gradient regime.
Why initialization matters — symmetry breaking: If all weights are initialized to the same value, every neuron in a layer computes the same gradient and updates identically. All neurons remain identical throughout training — the network has effectively one neuron per layer. Random initialization breaks this symmetry.
Why scale matters: Consider a linear layer with fan_in = 512. If weights are initialized from N(0, 1), the output variance is 512 × 1 = 512. After a few layers, activations are O(512^L/2) where L is depth — they explode. The initialization schemes below solve this by scaling variance to keep activations ~unit variance throughout the network.
Scheme |
Variance Formula |
Activation |
Status |
|---|---|---|---|
Xavier / Glorot |
|
tanh, sigmoid |
Standard for older architectures |
Kaiming / He |
|
ReLU |
Default for modern CNNs and MLPs |
LeCun |
|
SELU |
Used with self-normalizing networks |
import torch.nn as nn
# PyTorch defaults are already Kaiming uniform for Linear and Conv layers.
# Explicit initialization:
layer = nn.Linear(512, 256)
nn.init.kaiming_normal_(layer.weight, mode='fan_in', nonlinearity='relu')
nn.init.zeros_(layer.bias)
# Xavier for tanh layers:
nn.init.xavier_uniform_(layer.weight)
# What happens with constant init (demonstrates the problem):
nn.init.constant_(layer.weight, 0.0) # all neurons identical — symmetry NOT broken
What bad initialization looks like: Run a forward pass and print layer.output.std() at each layer. If it’s 0.01 (vanishing) or 100 (exploding) before any training has occurred, your initialization is wrong.
3. BatchNorm — And the Most Common PyTorch Bug¶
Batch Normalization normalizes the output of a layer across the mini-batch dimension, then applies learned scale γ and shift β to allow the network to undo the normalization if needed.
Mathematical definition for a mini-batch of size B:
μ_B = (1/B) Σ x_i # batch mean
σ²_B = (1/B) Σ (x_i - μ_B)² # batch variance
x̂_i = (x_i - μ_B) / sqrt(σ²_B + ε) # normalize
y_i = γ · x̂_i + β # scale and shift (γ, β are learned)
γ and β are learnable parameters initialized to 1 and 0. The network can learn γ = σ and β = μ to effectively pass through unchanged — BatchNorm can learn to do nothing if that’s optimal.
Train mode vs eval mode — the most common PyTorch bug:
During training, BatchNorm uses the current mini-batch statistics (μ_B, σ²_B). It also maintains exponential moving averages of these statistics as running_mean and running_var.
During evaluation (inference), BatchNorm must use the running statistics — not the batch statistics — because:
You might be predicting on a single sample (batch size = 1, making batch statistics meaningless)
You want deterministic, reproducible predictions independent of what other samples happen to be in the batch
If you forget model.eval() at inference, BatchNorm uses batch statistics → wildly wrong predictions on small batches or single samples.
import torch
import torch.nn as nn
# ── Demonstrating the BatchNorm train/eval bug ──────────────────────────────
model = nn.Sequential(
nn.Linear(10, 64),
nn.BatchNorm1d(64),
nn.ReLU(),
nn.Linear(64, 1),
)
x_single = torch.randn(1, 10) # single sample at inference
# ❌ WRONG: model is in train mode, BatchNorm uses batch stats
# With B=1, variance = 0, division by near-zero → NaN or garbage
model.train()
out_wrong = model(x_single)
print(f"Train mode output (B=1): {out_wrong.item():.4f}") # often NaN or extreme value
# ✅ CORRECT: model.eval() switches BatchNorm to use running stats
model.eval()
with torch.no_grad():
out_correct = model(x_single)
print(f"Eval mode output (B=1): {out_correct.item():.4f}") # stable, reproducible
# ── Verifying running stats are populated ──────────────────────────────────
# Train on some data first, then evaluate:
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
model.train()
for _ in range(100):
x_batch = torch.randn(32, 10)
loss = model(x_batch).mean()
optimizer.zero_grad(); loss.backward(); optimizer.step()
# Now eval mode uses the running stats accumulated over 100 batches
model.eval()
with torch.no_grad():
out = model(x_single)
print(f"After training, eval mode (B=1): {out.item():.4f}") # correct
The three-line fix you must memorize:
model.eval() # switch to eval mode before any inference
with torch.no_grad(): # disable gradient computation (also saves memory)
output = model(input)
4. Dropout¶
Dropout randomly zeros a fraction p of neurons during each forward pass in training. Each neuron is zeroed independently with probability p.
Mathematical definition: For a neuron with output x, dropout produces:
x_dropped = x · Bernoulli(1 - p) / (1 - p)
The / (1 - p) scaling keeps the expected value of x_dropped equal to x — this is inverted dropout, which is what PyTorch implements.
Why it works — ensemble interpretation: With n neurons, dropout samples a different sub-network at each training step. There are 2^n possible sub-networks. Training with dropout is equivalent to training and averaging over an exponentially large ensemble — at inference, all neurons are active (scaled appropriately), approximating the ensemble mean.
import torch.nn as nn
# Dropout for classic MLPs: p=0.5 is standard
dropout_heavy = nn.Dropout(p=0.5)
# Dropout for transformers: p=0.1 is standard (attention already regularizes)
dropout_light = nn.Dropout(p=0.1)
# CRITICAL: Dropout is automatically disabled in eval mode
model.train() # Dropout active
model.eval() # Dropout disabled — all neurons contribute at full scale
p values by context:
Classic MLP, dense layers:
p=0.3–0.5Transformers (attention, feedforward):
p=0.1CNNs: often
p=0.0–0.25(BatchNorm provides regularization already; too much dropout hurts)
5. Gradient Clipping¶
Gradient clipping caps the L2 norm of all gradients before the optimizer step. It prevents exploding gradients — particularly relevant in RNNs (BPTT), deep transformers during unstable phases, and any model trained with high learning rates.
import torch
# Standard pattern — call AFTER loss.backward(), BEFORE optimizer.step()
loss.backward()
# Clip the global gradient norm to max_norm=1.0
# Returns the norm before clipping — useful for monitoring
grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
# Log grad_norm to detect instability:
# Consistently high (>10): LR too high or bad initialization
# Suddenly spikes then drops: gradient explosion that clipping caught
# Consistently near 0: vanishing gradients, model isn't learning
print(f"Gradient norm: {grad_norm:.4f}")
When to clip: Always for RNNs/LSTMs. For transformers, standard practice is max_norm=1.0. For CNNs, only if you observe NaN losses or loss spikes. The overhead is negligible — it’s a single norm computation.
6. Mixed Precision Training¶
Modern GPUs have dedicated hardware for FP16 and BF16 operations (Tensor Cores on NVIDIA). Mixed precision trains in lower precision for forward/backward passes and accumulates gradients in FP32, reducing VRAM by ~40–50% and increasing throughput.
FP16 vs BF16:
Format |
Exponent bits |
Mantissa bits |
Range |
Notes |
|---|---|---|---|---|
FP32 |
8 |
23 |
±3.4×10³⁸ |
Full precision baseline |
FP16 |
5 |
10 |
±65,504 |
Overflow risk with large activations |
BF16 |
8 |
7 |
±3.4×10³⁸ |
Same range as FP32; preferred for LLMs |
Why BF16 is preferred for LLMs: BF16 has the same 8-bit exponent as FP32, so it can represent the same dynamic range without overflow. FP16’s 5-bit exponent caps at ~65,504 — large activations overflow to Inf, causing NaN losses. On NVIDIA Ampere/Hopper and newer GPUs, BF16 Tensor Cores are available and BF16 is the default choice.
import torch
from torch.cuda.amp import autocast, GradScaler
model = YourModel().cuda()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
# GradScaler prevents FP16 underflow by scaling the loss up before backward
# (Only needed for FP16; BF16 doesn't require scaling)
scaler = GradScaler() # use with FP16; omit or set enabled=False for BF16
for batch in dataloader:
inputs, targets = batch
# autocast selects the lower-precision dtype automatically
with autocast(device_type='cuda', dtype=torch.bfloat16): # or torch.float16
outputs = model(inputs)
loss = criterion(outputs, targets)
# FP16 path: scale loss to prevent gradient underflow
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(optimizer)
scaler.update()
# BF16 path (simpler — no scaler needed):
# loss.backward()
# torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
# optimizer.step()
# optimizer.zero_grad()
Memory savings in practice: A ResNet-50 in FP32 uses ~97MB for weights. In BF16: ~48MB. Activations and gradients scale similarly. For a 7B parameter LLM: FP32 ≈ 28GB, BF16 ≈ 14GB — the difference between fitting on one A100 and needing two.
7. Debugging Training: A Diagnostic Checklist¶
Training failures have a finite set of root causes. Most of them are identifiable from the loss curve and gradient norm alone if you know what to look for.
Before debugging, instrument your training loop with these three metrics: train_loss, val_loss, and grad_norm. Without them, you are debugging blind.
Symptom |
Root Causes |
Diagnostic |
Fix |
|---|---|---|---|
Loss → NaN |
LR too high; log(0) in loss; bad init; exploding gradients |
Check grad_norm spike; print |
Reduce LR 10×; add gradient clipping; check for log(0+ε) |
Loss plateaus early |
LR too low; dying ReLUs; wrong architecture |
Check if gradients near 0; inspect activation histograms |
Increase LR; switch to LeakyReLU; add BatchNorm |
Loss oscillates |
LR too high; batch size too small |
Loss curve looks like high-frequency noise |
Reduce LR; increase batch size; add gradient clipping |
Train↓ Val↑ (overfitting) |
Model too large; insufficient data; no regularization |
Large train/val gap by epoch 10 |
Add Dropout; augmentation; weight decay; early stopping |
Train↓ Val stays flat |
Data distribution mismatch; val set is harder |
Consistent gap from epoch 1 |
Check val preprocessing matches train; check class imbalance |
Model learns nothing |
Wrong loss function; wrong labels; frozen layers accidentally |
Loss = log(num_classes) and stays there |
Verify labels; check requires_grad; test with tiny batch overfit |
The single most useful debugging technique: Before training the full dataset, try to overfit a single batch of 32 samples to near-zero loss. If you can’t do that, there’s a bug in your model or training loop — not a data problem, not a hyperparameter problem. Fix the bug first.
# ── Single-Batch Overfit Test ───────────────────────────────────────────────
model.train()
x_debug, y_debug = next(iter(train_loader))
x_debug, y_debug = x_debug.to(DEVICE), y_debug.to(DEVICE)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-2) # high LR to overfit fast
for step in range(200):
optimizer.zero_grad()
logits = model(x_debug)
loss = criterion(logits, y_debug)
loss.backward()
optimizer.step()
if step % 20 == 0:
print(f"Step {step:3d} | loss={loss.item():.6f}")
# Expected: loss should reach <0.001 within 200 steps
# If it doesn't: model architecture bug, loss function mismatch, or frozen layers
What Most People Get Wrong¶
The BatchNorm
train()vseval()distinction. This single bug has burned more debugging sessions than almost any other PyTorch pitfall.
The mechanism: model.train() sets BatchNorm (and Dropout) to training behavior. model.eval() switches BatchNorm to use its running statistics and disables Dropout. If you run inference in train() mode, every prediction depends on the other samples in the batch — your model’s output for sample A changes if you change sample B. On batch size 1, variance = 0, and the normalization collapses.
The fix is two lines: model.eval() and with torch.no_grad(). The fact that this is two lines that must both be remembered is why this bug persists. Write a helper:
@torch.no_grad()
def predict(model, x):
model.eval()
return model(x)
A secondary subtlety: model.eval() only affects BatchNorm and Dropout. It does not disable gradient computation — that’s what torch.no_grad() does. You need both for correct, memory-efficient inference.
Return to README.md · Next: 06_phase_projects.md