Optimisers, Schedules & Regularisation That Actually Work
The practical half of deep learning: how to pick an optimiser, set a learning rate, stop a run from diverging, and regularise without destroying capacity.
Covers: SGD vs Adam vs AdamW, learning rate schedules, warmup, gradient clipping, dropout, label smoothing, mixed precision
Architecture questions get the attention, but training questions decide the hire. Anyone can describe a transformer; far fewer can explain why their loss spiked at step 3,000, why AdamW exists, or what warmup is actually fixing.
30-second answer
SGD+momentum uses one global learning rate and an EMA of gradients; it generalises well but needs careful tuning. Adam adds per-parameter adaptive scaling by an EMA of squared gradients, which handles badly-scaled and sparse gradients and converges fast. AdamW exists because adding L2 to the loss under Adam gets divided by the adaptive denominator, so large-gradient parameters get *less* decay — AdamW decouples decay from the adaptive step. SGD still wins for vision CNNs where it often generalises 1-2% better.
Adam. m̂ and v̂ are bias-corrected: m̂ = m/(1−β₁ᵗ).
The division by is the whole idea: parameters whose gradients have been consistently large get smaller steps, and parameters with tiny gradients get relatively larger ones. It is an automatic per-parameter learning rate, which is why Adam works out of the box on architectures where a single global LR would be hopeless.
The AdamW fix, precisely
Classic L2 regularisation adds to the gradient. Under Adam that term flows into both and , so the effective decay becomes — inversely proportional to the parameter's gradient magnitude. Parameters that need regularising most (large, active weights) receive the least. AdamW removes the term from the gradient and applies it directly to the update:
import torch
def build_optimizer(model, lr=3e-4, weight_decay=0.1):
"""Never decay biases, norms or embeddings -- decaying them just underfits."""
decay, no_decay = [], []
for name, p in model.named_parameters():
if not p.requires_grad:
continue
if p.ndim <= 1 or name.endswith(".bias") or "norm" in name.lower():
no_decay.append(p) # LayerNorm/RMSNorm weights, all biases
else:
decay.append(p) # weight matrices and conv kernels
return torch.optim.AdamW(
[{"params": decay, "weight_decay": weight_decay},
{"params": no_decay, "weight_decay": 0.0}],
lr=lr, betas=(0.9, 0.95), eps=1e-8) # beta2=0.95 is the LLM convention
opt = build_optimizer(model)
print(f"decayed tensors: {len(opt.param_groups[0]['params'])}, "
f"undecayed: {len(opt.param_groups[1]['params'])}")
# Memory reality check: Adam stores m and v per parameter, in fp32.
n = sum(p.numel() for p in model.parameters())
print(f"params {n/1e9:.2f}B -> weights {4*n/1e9:.1f}GB + optimizer state {8*n/1e9:.1f}GB")
# params 7.00B -> weights 28.0GB + optimizer state 56.0GB
# This 3x multiplier is exactly why ZeRO/FSDP shard optimizer state first.| Optimiser | Memory / param | Best for | Typical LR |
|---|---|---|---|
| SGD | 0 extra | Simple convex problems | 0.1 (with schedule) |
| SGD + momentum | 1× | Vision CNNs, best generalisation | 0.1 → 0.01 |
| Adam / AdamW | 2× | Transformers, NLP, RL, anything sparse | 1e-4 → 3e-4 |
| Adafactor | ~0× | Very large models, memory-constrained | 1e-3 |
| Lion | 1× | Large-scale, memory-conscious alternative | ~3–10× smaller than AdamW |
| 8-bit Adam | 0.5× | Fine-tuning on consumer GPUs | same as AdamW |
Say these points
- Adam = momentum + per-parameter scaling by an EMA of squared gradients.
- L2-in-loss under Adam gets divided by √v, so decay is inversely proportional to gradient size — AdamW decouples it.
- Never weight-decay biases, norm parameters or (usually) embeddings.
- Adam costs 2× parameter memory in optimiser state — the reason ZeRO shards it first.
- SGD+momentum still generalises better on vision CNNs; AdamW dominates transformers.
Avoid these mistakes
- Using `Adam(weight_decay=...)` and thinking it is the same as AdamW.
- Applying weight decay to LayerNorm gains and biases.
- Copying an LR from an SGD recipe into Adam (typically 100–1000× too large).
Expect these follow-ups
- Why does Adam need bias correction in the first few steps?
- How does Adafactor reduce optimiser memory to nearly zero?
- Explain how ZeRO stages 1–3 shard state across data-parallel ranks.
30-second answer
Warmup exists because Adam's second-moment estimate is unreliable in the first few hundred steps — a small v produces enormous updates that can permanently damage an untrained network. Decay exists because a large LR is needed to escape bad regions early but prevents settling into a minimum later. The standard transformer recipe is linear warmup over 1–5% of steps to a peak, then cosine decay to about 10% of peak.
What warmup actually fixes
At step 1, . Bias correction divides by , giving — an estimate based on a single sample. If that one gradient happens to be small, the update is enormous. Early large steps in a randomly initialised network push it into a region it may never recover from, which is why the failure shows up as a run that never converges rather than one that recovers.
There is a second, architecture-level reason. In post-norm transformers, gradients at the early layers are poorly scaled at initialisation, and a large first step produces exactly the divergence the original paper worked around with its inverse-square-root schedule. Pre-norm reduces but does not eliminate this.
import math, torch
def warmup_cosine(step, total_steps, warmup_steps, peak_lr, min_ratio=0.1):
if step < warmup_steps:
return peak_lr * step / max(1, warmup_steps) # linear warmup
progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
cosine = 0.5 * (1 + math.cos(math.pi * min(progress, 1.0)))
return peak_lr * (min_ratio + (1 - min_ratio) * cosine) # decay to 10% of peak
TOTAL, WARMUP, PEAK = 100_000, 2_000, 3e-4
for s in (0, 500, 2_000, 10_000, 50_000, 90_000, 100_000):
print(f"step {s:>6}: lr = {warmup_cosine(s, TOTAL, WARMUP, PEAK):.2e}")
# step 0: lr = 0.00e+00
# step 500: lr = 7.50e-05
# step 2000: lr = 3.00e-04 <- peak, end of warmup
# step 10000: lr = 2.93e-04
# step 50000: lr = 1.65e-04
# step 90000: lr = 3.73e-05
# step 100000: lr = 3.00e-05 <- 10% of peak
# --- LR range test: find the peak LR in ONE short run (Leslie Smith) ---
def lr_range_test(model, loader, opt, lo=1e-7, hi=1.0, n=200):
"""Exponentially ramp LR; the best peak is ~1 order below where loss diverges."""
mult, lr, history = (hi / lo) ** (1 / n), lo, []
for i, (x, y) in enumerate(loader):
if i >= n: break
for g in opt.param_groups: g["lr"] = lr
opt.zero_grad(); loss = criterion(model(x), y); loss.backward(); opt.step()
history.append((lr, float(loss)))
if history and float(loss) > 4 * min(h[1] for h in history):
break # diverged -- stop
lr *= mult
return history| Schedule | Shape | Use when |
|---|---|---|
| Warmup + cosine | Ramp up, smooth decay to ~10% of peak | The default for transformers and LLMs |
| Warmup + linear decay | Ramp up, straight line down | BERT-style fine-tuning; simple and robust |
| Inverse sqrt | η ∝ 1/√step after warmup | Original transformer; largely superseded |
| Step decay | ×0.1 at fixed epochs | Classic vision recipes (ResNet on ImageNet) |
| One-cycle | Up then down, with inverse momentum | Fast convergence on a fixed epoch budget |
| Constant + cooldown | Flat, then decay at the end | When total steps are unknown in advance |
- Warmup length: 1–5% of total steps, or a flat 2,000 for large runs. Longer warmup for larger batches.
- Peak LR: run an LR range test and take roughly one order of magnitude below divergence, or start from a known recipe and scale with batch size.
- LR ∝ 1/√width is a reasonable heuristic when scaling model size; μP (maximal update parametrisation) makes this transfer principled so you can tune on a small model and reuse the LR on a large one.
- Layer-wise LR decay (lower LR for earlier layers) is standard for fine-tuning pre-trained encoders and reliably beats a uniform LR.
Say these points
- Warmup fixes Adam's unreliable early second-moment estimate, which produces huge first steps.
- Decay lets you explore early and settle late.
- Standard recipe: linear warmup 1–5% of steps, then cosine decay to ~10% of peak.
- Cosine requires knowing total steps; use constant+cooldown when you do not.
- Find the peak LR with an LR range test rather than by guessing.
Avoid these mistakes
- Truncating a cosine schedule early and leaving the model at a high LR.
- Skipping warmup on a large-batch or post-norm transformer run.
- Using an LR tuned at one model width unchanged at a much larger width.
Expect these follow-ups
- What is μP and how does it make learning rates transfer across model sizes?
- How would you set the LR for LoRA fine-tuning versus full fine-tuning?
- Explain the WSD (warmup-stable-decay) schedule and why it is useful.
30-second answer
Check in this order: (1) is it a data problem — a corrupt batch, a huge outlier, or an all-padding sequence causing a division by zero; (2) numerical overflow in fp16, especially in attention scores or normalisation; (3) gradient explosion, visible as a rising gradient norm before the spike; (4) a stale Adam second moment meeting a sudden large gradient. Fixes: clip gradients, lower β₂, switch fp16→bf16, and always keep the last few checkpoints so you can skip the offending batch.
The diagnostic ladder
1 — Inspect the offending batch
Log the batch index deterministically. Look for: NaN/Inf in the inputs, a sequence that is entirely padding (mean over zero valid tokens → 0/0), a duplicated document repeated thousands of times, or a token id outside the embedding range. Corrupt data is by far the most common cause and the cheapest to rule out.2 — Check for fp16 overflow
fp16's maximum is 65,504. Attention logits before softmax, and the sum inside a normalisation, routinely exceed it in a large model. bf16 has the same exponent range as fp32 (max ≈ 3×10³⁸) and simply does not have this failure mode — which is why every modern large-scale run uses bf16.3 — Look at the gradient norm history
Log global grad norm every step. A spike almost never comes from nowhere: you usually see the norm climbing over the preceding hundreds of steps. If so, the problem started long before the NaN and clipping alone is treating the symptom.4 — Suspect the Adam second moment
With β₂ = 0.999 the effective window is ~1000 steps. If gradients have been small for a while, v is small; one large gradient then produces a gigantic update because it is divided by a stale, tiny √v. Lower β₂ to 0.95, or use an update-clipping variant.5 — Check for architectural hot spots
Unnormalised attention logits, a missing ε in a normalisation denominator, alogof something that can be zero, orexpof an unbounded value. Attention logit soft-capping (tanh clamping) is used in several recent models specifically to prevent this.
import torch, math
class TrainingMonitor:
"""Log the signals that actually diagnose instability."""
def __init__(self, model, spike_factor=4.0, window=100):
self.model, self.history = model, []
self.spike_factor, self.window = spike_factor, window
def step(self, loss, step_idx, batch=None):
if not math.isfinite(float(loss)):
raise RuntimeError(f"non-finite loss at step {step_idx}")
gnorm = torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
gnorm = float(gnorm)
self.history.append(gnorm)
recent = self.history[-self.window:]
median = sorted(recent)[len(recent) // 2]
if len(recent) == self.window and gnorm > self.spike_factor * median:
print(f"[step {step_idx}] GRAD SPIKE {gnorm:.2f} vs median {median:.2f}")
# Find which module is responsible
per_layer = {n: float(p.grad.norm())
for n, p in self.model.named_parameters()
if p.grad is not None}
for n, v in sorted(per_layer.items(), key=lambda kv: -kv[1])[:5]:
print(f" {n:50s} {v:.3f}")
if batch is not None:
torch.save(batch, f"spike_batch_{step_idx}.pt") # save it for offline study
return "skip" # skip this batch rather than corrupt the weights
return "ok"
# [step 11987] GRAD SPIKE 41.83 vs median 0.94
# model.layers.31.mlp.down_proj.weight 38.114
# model.layers.31.self_attn.o_proj.weight 9.882
# -> last layer, MLP: check for an outlier document or an fp16 overflow| Symptom | Likely cause | Fix |
|---|---|---|
| NaN immediately at step 0 | Bad init, LR far too high, or corrupt data | Reduce LR 10×, check inputs, verify init |
| Loss spikes then recovers | Rare hard batch; Adam second moment adapting | Usually benign; clip gradients, consider β₂ = 0.95 |
| Loss spikes and never recovers | Weights corrupted by one huge update | Restart from checkpoint, skip the batch, clip harder |
| Only under fp16, fine in fp32 | Overflow (max 65,504) | Switch to bf16, or scale attention logits |
| Only at large batch size | LR scaled up without warmup | Longer warmup, re-check the linear scaling rule |
| Only in distributed runs | Gradient all-reduce or a rank with a bad shard | Verify grad-norm parity across ranks; check the data sharding |
Say these points
- First determine whether the spike is reproducible at the same step — data vs numerics.
- fp16 overflows at 65,504; bf16 has fp32's exponent range and avoids the whole class.
- Log global and per-layer gradient norms; spikes are usually preceded by a rising trend.
- A stale Adam v meeting a large gradient produces a huge update; lower β₂ to 0.95.
- Operationally: frequent checkpoints, automatic rollback, and batch-skipping.
Avoid these mistakes
- Lowering the learning rate as a reflex without diagnosing the cause.
- Clipping gradients so aggressively that learning stops.
- Not saving the offending batch, so the root cause is never found.
Expect these follow-ups
- Why does bf16 not need loss scaling while fp16 does?
- How does gradient clipping by global norm differ from per-parameter clipping?
- How would you detect a single bad rank in a 512-GPU run?
30-second answer
Data augmentation first — it adds real information rather than constraining capacity. Then early stopping (free), weight decay, dropout, label smoothing, and finally architectural reductions. For transformers specifically, dropout matters much less than for CNNs and is often set to 0 during large-scale pre-training, where data volume alone provides the regularisation.
| Technique | Mechanism | Typical setting | Priority |
|---|---|---|---|
| Data augmentation | Expands the effective dataset; encodes invariances | Vision: crop/flip/RandAugment/mixup. Text: back-translation, paraphrase | 1 |
| Early stopping | Limits the effective capacity actually reached | Patience 3–10 on validation metric | 1 (free) |
| Weight decay | Shrinks weights toward zero; prefers simpler functions | 0.01–0.1 for AdamW transformers | 2 |
| Dropout | Prevents co-adaptation; approximates an exponential ensemble | 0.1 transformers, 0.3–0.5 dense CNN heads | 3 |
| Label smoothing | Prevents the logits from diverging toward infinite confidence | ε = 0.1 | 3 |
| Stochastic depth | Randomly skips residual blocks; shortens the expected path | Linearly increasing to 0.1–0.2 by depth | 4 (deep nets) |
| Mixup / CutMix | Trains on convex combinations of inputs and labels | α = 0.2–0.4 | 4 (vision) |
Dropout: the mechanism and the inference detail
Dropout zeroes each unit independently with probability during training. Two complementary readings: it prevents co-adaptation (a unit cannot rely on a specific partner always being present) and it approximately trains an exponentially large ensemble of subnetworks that share weights, then averages them at test time.
import torch
def inverted_dropout(x, p, training=True):
"""PyTorch's actual implementation: scale during TRAINING, no-op at inference."""
if not training or p == 0.0:
return x
mask = (torch.rand_like(x) > p).float()
return x * mask / (1.0 - p) # <-- the 1/(1-p) rescale keeps E[output] constant
x = torch.ones(10_000)
print("train mode mean:", round(float(inverted_dropout(x, 0.5, True).mean()), 4)) # ~1.0
print("eval mode mean:", round(float(inverted_dropout(x, 0.5, False).mean()), 4)) # 1.0
# Scaling at train time means inference is a plain forward pass with zero overhead.
# The classic bug: forgetting model.eval(), so dropout stays active in production
# and predictions become non-deterministic and worse.Label smoothing
With a hard one-hot target, cross-entropy is only minimised as the correct logit goes to — so training pushes toward unbounded confidence, which produces overconfident, poorly-calibrated models. Label smoothing gives a finite optimum, improves calibration, and typically adds a small accuracy gain. The trade-off: it compresses the representation space and is known to slightly hurt when the model is used as a feature extractor for downstream retrieval or distillation.
- Augmentation is not regularisation in the same sense — it adds information rather than removing capacity. That is why it is strictly better when available.
- Mixup trains on with correspondingly mixed labels. It improves calibration and adversarial robustness beyond its accuracy gain.
- Stochastic depth is dropout over whole residual blocks; it makes very deep networks behave like an ensemble of shallower ones and cuts training time.
- Ensembling is the reliable last resort. Five seeds averaged beats almost any single-model regularisation tweak, at 5× inference cost — or 1× if you distil the ensemble back into one model.
Say these points
- Augmentation and early stopping first — they add information or cost nothing.
- Inverted dropout scales at train time so inference is a plain forward pass.
- Forgetting model.eval() is a classic production bug.
- Label smoothing bounds the optimum and improves calibration, at some cost to representation quality.
- Dropout is often 0 in large-scale pre-training; it returns for small-data fine-tuning.
Avoid these mistakes
- Stacking every regulariser at once and then underfitting.
- Applying dropout right before a BatchNorm layer (they interact badly).
- Using label smoothing when the embeddings feed a retrieval or distillation task.
Expect these follow-ups
- Why do dropout and batch normalisation interact badly?
- How does mixup improve calibration as well as accuracy?
- When would you prefer stochastic depth over dropout?
30-second answer
Mixed precision runs matmuls in 16-bit for ~2× tensor-core throughput and half the activation memory, while keeping a master copy of weights and the optimiser state in fp32 for update precision. fp16 has only 5 exponent bits and overflows at 65,504, so it needs dynamic loss scaling. bf16 has 8 exponent bits — the same range as fp32 — so it never overflows and needs no loss scaling, at the cost of less mantissa precision.
| Format | Bits (S/E/M) | Max value | Relative precision | Loss scaling |
|---|---|---|---|---|
| fp32 | 1 / 8 / 23 | 3.4 × 10³⁸ | ~7 decimal digits | N/A |
| fp16 | 1 / 5 / 10 | 65,504 | ~3 digits | Required |
| bf16 | 1 / 8 / 7 | 3.4 × 10³⁸ | ~2 digits | Not needed |
| fp8 (E4M3) | 1 / 4 / 3 | 448 | ~1 digit | Per-tensor scaling |
The trade is entirely between range (exponent bits) and precision (mantissa bits). Deep learning turns out to care far more about range: gradients span many orders of magnitude, but you rarely need seven significant digits in a weight update that gets averaged over thousands of examples.
import torch
from torch.amp import autocast, GradScaler
# ---- bf16: the modern default on A100/H100/TPU ----
for x, y in loader:
with autocast("cuda", dtype=torch.bfloat16):
loss = criterion(model(x), y)
loss.backward() # no scaler needed
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step(); optimizer.zero_grad(set_to_none=True)
# ---- fp16: needed on older GPUs (V100, T4, consumer cards) ----
scaler = GradScaler()
for x, y in loader:
with autocast("cuda", dtype=torch.float16):
loss = criterion(model(x), y)
scaler.scale(loss).backward() # multiply loss so small grads survive fp16
scaler.unscale_(optimizer) # MUST unscale before clipping
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(optimizer) # skips the step if any grad is inf/nan
scaler.update() # adapts the scale factor automatically
optimizer.zero_grad(set_to_none=True)
# Why loss scaling exists: fp16's smallest normal value is ~6e-5.
print(torch.tensor(1e-8, dtype=torch.float16)) # tensor(0., dtype=float16)
print(torch.tensor(1e-8 * 1024, dtype=torch.float16)) # tensor(1.0252e-05) <- survivesMemory accounting for a 7B model
| Component | Precision | Bytes/param | 7B total |
|---|---|---|---|
| Weights (compute copy) | bf16 | 2 | 14 GB |
| Master weights | fp32 | 4 | 28 GB |
| Adam momentum (m) | fp32 | 4 | 28 GB |
| Adam variance (v) | fp32 | 4 | 28 GB |
| Gradients | bf16 or fp32 | 2–4 | 14–28 GB |
| Total (excl. activations) | 16–18 | 112–126 GB |
For the frontier angle: fp8 training (E4M3 for forward, E5M2 for gradients) is production on H100 with per-tensor scaling factors, giving another ~2× on top of bf16. And for inference, weight-only quantisation to int8/int4 is standard — but that is a separate topic from training precision, and conflating the two is a common candidate mistake.
Say these points
- Mixed precision: 16-bit matmuls, fp32 master weights and optimiser state.
- fp16 has 5 exponent bits and overflows at 65,504 → dynamic loss scaling required.
- bf16 matches fp32's range, so no loss scaling; it trades mantissa precision instead.
- Training a 7B model needs ~112 GB — optimiser state dominates, not weights.
- Keep softmax, normalisation statistics and the optimiser update in fp32.
Avoid these mistakes
- Clipping gradients before unscaling with a GradScaler.
- Assuming bf16 is strictly better — it has fewer mantissa bits and can hurt small-scale fine-tuning.
- Confusing training precision with inference quantisation.
Expect these follow-ups
- How do ZeRO stages 1, 2 and 3 shard optimiser state, gradients and parameters?
- Why does gradient checkpointing reduce memory, and what is the compute cost?
- How does fp8 training keep numerics stable with only 4 exponent bits?
Showing 5 of 5 questions for training-optimization-and-regularization.
Check your understanding
5 questions · no sign-up, nothing stored
Hands-on challenge
Build it — this is what you talk about in a deep-dive round.
Instrument a training run end to end
Take any small transformer or CNN and build the observability you would want on a real run.
Requirements
- Log per-step: loss, global gradient norm, learning rate, and per-layer gradient norms every N steps.
- Implement warmup + cosine schedule and an LR range test; report the peak LR the test suggests.
- Deliberately induce a divergence (LR 100×, or an injected outlier batch) and show your monitor catches it before NaN.
- Compare AdamW with and without correct no-decay parameter groups; report the difference.
Stretch goals
- Run the same config in fp32, fp16+scaler and bf16; compare wall-clock, peak memory and final loss.
- Implement gradient accumulation and verify numerically that it matches a true large batch.