Ch. 11
Training Deep Networks
Init schemes, BatchNorm vs LayerNorm vs RMSNorm, AdamW vs Lion vs Muon, LR schedules, Karpathy's recipe.
A 5-layer MLP trains. A 50-layer MLP does not. The reason is not that the loss surface is harder, although it is. The reason is mechanical: the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → signal at the input has to survive 50 multiplications by Jacobians, and unless every one of them has a singular spectrum close to 1, the signal either vanishes or explodes. Most of what we call "modern training" is a stack of fixes for that one problem. Better initialization keeps the Jacobian spectra close to 1 at step zero. Normalization layers re-normalize the activation distribution at every layer to keep them close to 1 throughout training. Skip connections add an identity term so the signal has a path that does not depend on the Jacobian product at all. Adaptive optimizers like AdamW per-One of the model's internal numbers that gets adjusted as it learns.Full glossary →-rescale so that even slow-moving directions update meaningfully. Learning-rate Starting training with tiny steps that grow for a little while before the main plan kicks in.Full glossary → and cosine decay shape the trajectory through the loss landscape so the model spends time where the geometry is friendly. Each of these is a chapter section. By the end of this chapter you will have trained a 30-layer transformer block on a synthetic task without it diverging once, and you will know which knob to turn when it does.
FIG 11.1 · Learning outcomes
By the end of this chapter you will be able to:
- Diagnose vanishing or exploding gradients by reading the per-layer gradient-norm log, and pick a fix (init, normalization, residuals) for each pattern.
- Initialize a deep network with Xavier, Kaiming, or μP, and explain when each is appropriate.
- Insert BatchNorm, LayerNorm, or RMSNorm into the right place in a network architecture, and articulate the difference (per-batch vs per-token vs per-token-without-mean).
- Tune dropout, weight decay, and label smoothing as a coordinated regularization stack, not three independent knobs.
- Choose between AdamW, Lion, and Muon based on the model size and the parameter count of the layer they update.
- Configure a learning-rate schedule (warmup + cosine, 1cycle, or warm restarts) with sensible defaults for your model scale.
- Fine-tune a pretrained model with discriminative learning rates without catastrophic forgetting.
- Run a hyperparameter search with Optuna and pick the right search space and acquisition function for the budget you have.
- Debug a training run that loses, NaN-spikes, or plateaus, by working through the standard checklist from Karpathy's recipe.
FIG 11.2 · What you need first
- Ch 9 — Introduction to Neural Networks — you need to know what a forward pass, a backward pass, and gradient descent are. This chapter assumes them.
- Ch 10 — PyTorch Foundations — every code snippet here is in PyTorch with
nn.Module,optim.AdamW,DataLoader. The training loop pattern is the substrate of this chapter. - Ch 4 — Training Models — gradient descent, learning rates, the bias-variance tradeoff. This chapter generalizes to depth.
If you have built a transformer already and bounced to this chapter, fine. The material is order-independent once you have the prereqs.
FIG 11.3.1
Why training deep networks is hard
Train a 3-layer MLP on MNIST with random init and SGD: 98% test The share of guesses the model got right out of all its guesses.Full glossary → in 30 seconds. Train a 30-layer MLP with the same setup: loss stuck at , accuracy at chance level. Same model class, same optimizer, same data, ten times more parameters. What changed?
The signal that has to propagate is the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → at the loss with respect to the first layer's weights. By the chain rule, that gradient is the product of Jacobians from layer 30 back to layer 1 — each Jacobian is the matrix of partial derivatives of one layer's outputs with respect to its inputs, the matrix How well the model handles brand-new examples it never studied.Full glossary → of the scalar local derivatives you chained in another chapter. A Jacobian stretches some directions and shrinks others; its singular values are those per-direction stretch factors, and the set of them is its spectrum. If each Jacobian has a typical singular value , the gradient norm shrinks (or grows) by . For , that's . For , that's . Either way, the first-layer gradient is wrong by orders of magnitude relative to the last-layer gradient. The optimizer sees a gradient signal that is dominated by the layers nearest the output, and the early layers barely move.
This is the vanishing/exploding gradients problem (Bengio et al. 1994). It is not philosophical. It is the product of a bunch of singular values, and you can compute it. The whole edifice of modern deep learning — better activations, better init, normalization, residuals — is a stack of fixes for that one product.
There are five mechanical fixes, and each gets its own section below:
- Activations that do not saturate (The most common bend in neural networks: it keeps positive numbers as they are and turns any negative number into zero.Full glossary → and descendants).
- Initialization that keeps the per-layer Jacobian's expected singular values near 1 (Xavier, Kaiming, μP).
- Normalization that re-centers the activation distribution at every layer (BatchNorm, LayerNorm, RMSNorm).
- Residual connections that give the gradient a shortcut path independent of the layer's Jacobian (ResNet, transformer blocks).
- Adaptive optimizers that rescale the update per-One of the model's internal numbers that gets adjusted as it learns.Full glossary → so slow-moving directions still move (Adam, AdamW, Muon).
Stack all five and you can train hundreds of layers. Drop any one and you usually cannot.
FIG 11.3.2
Weight initialization: Xavier, Kaiming, μP
A randomly-initialized linear layer maps input variance to output variance. If the weights are drawn from a normal with variance , then for input with components of unit variance, the output has components of variance . To preserve variance across the layer, set .
That is the core of every init scheme. The variants differ in two ways: whether they account for fan-out and the non-linearity.
Xavier / Glorot initialization (Glorot and Bengio 2010) targets unit variance for both forward and backward passes. The compromise: . It assumes the activation has unit derivative near zero, which is true for tanh but not for The most common bend in neural networks: it keeps positive numbers as they are and turns any negative number into zero.Full glossary →. Use it with tanh or A function that takes any number and squeezes it into a single confidence between 0 and 1.Full glossary →.
Kaiming / He initialization (He et al. 2015) targets unit variance only in the forward direction, and accounts for ReLU killing half the activations on average. The fix is to scale up by : . Use it with ReLU and its variants (GELU, SiLU, SwiGLU).
μP — maximal update parameterization (Yang and Hu 2021) is the modern frontier. The insight is that the right per-One of the model's internal numbers that gets adjusted as it learns.Full glossary → learning-rate Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → depends on the layer's The number of inputs feeding into one neuron, which decides how small to set its starting values.Full glossary → and fan-out, in a way that lets you tune hyperparameters at small width and transfer them to large width without retuning. For Llama-scale training this is the difference between "we ran 50 small ablations to find the right LR for the 70B model" and "we ran 5 small ablations and the LR transferred". μP is more involved to implement, and unless you are training at frontier scale, Kaiming is fine.
PyTorch's defaults: nn.Linear uses Kaiming uniform with a=sqrt(5), which is roughly Kaiming-for-LeakyReLU. It's not optimal but it does not actively break anything. Override it for serious work — nn.init.kaiming_normal_ against the explicit sqrt(2/fan_in) rule:
nn.init.kaiming_normal_ vs. sqrt(2/fan_in)
DL gluedef init_weights(m):
if isinstance(m, nn.Linear):
nn.init.kaiming_normal_(m.weight, nonlinearity="relu")
if m.bias is not None:
nn.init.zeros_(m.bias)
model.apply(init_weights)def init_kaiming_(model):
for m in model.modules():
if isinstance(m, nn.Linear):
nn.init.kaiming_normal_(m.weight, nonlinearity="relu")
if m.bias is not None:
nn.init.zeros_(m.bias)
# the variance the call computes, spelled out (draft.md §2):
# if init == "kaiming": sigma = np.sqrt(2.0 / d_in)
# elif init == "xavier": sigma = np.sqrt(2.0 / (d_in + d_out))
# W = rng.standard_normal((d_in, d_out)) * sigma; b = np.zeros(d_out)from scratch: lab/solution.py: init_kaiming_
- 1
nn.init.kaiming_normal_(W, nonlinearity='relu')sigma = sqrt(2.0 / d_in); W = standard_normal(shape) * sigma - 2
the gain=sqrt(2) for relu baked into kaimingthe factor of 2 in sqrt(2.0 / d_in) - 3
fan_in computed from the weight shapethe d_in in the denominator - 4
nn.init.zeros_(m.bias)self.b = np.zeros(d_out) - 5
model.apply(init_weights) walking every submodulefor m in model.modules(): if isinstance(m, nn.Linear)
What the one call hides
- kaiming_normal_ defaults to mode='fan_in' and nonlinearity='leaky_relu' with a=0; you MUST pass nonlinearity='relu' to get the sqrt(2) gain this scratch uses.
- It computes fan_in/fan_out from the tensor shape automatically (and for conv layers folds in the receptive-field size), which the manual sqrt(2/d_in) only approximates for plain Linear.
- PyTorch's default nn.Linear init is kaiming_uniform_ with a=sqrt(5) (LeakyReLU-ish), NOT this relu-Kaiming, so without the override you are silently on a different scheme.
- kaiming_normal_ initializes in place and ignores existing values; biases are left untouched unless you zero them yourself.
- Gotcha: Forgetting nonlinearity='relu' leaves you on the leaky_relu default gain, undershooting the variance for ReLU nets.
- Gotcha: kaiming assumes the fan it derives matches your activation; using it before tanh/sigmoid is the wrong scheme (use xavier there).
- Gotcha: model.apply re-inits every matching module including any you wanted to keep pretrained; call it before loading checkpoints, not after.
Use nn.init.kaiming_normal_ via model.apply in production because it gets fan-in (and conv receptive fields) right for free; the from-scratch sqrt(2/d_in) shows the one number that matters is the per-layer variance, and lets you implement schemes (muP, lecun, custom gains) the stock initializers do not directly provide.
On the job: You write the apply(init_fn) walker that decides which layers get which scheme (relu-Kaiming on hidden Linears, special-casing the output/embedding/residual-scaled layers); the per-weight draw is the library's, the policy is yours.
FIG 11.3.3
BatchNorm vs LayerNorm vs RMSNorm
A normalization layer takes a A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary →, computes per-axis statistics, and rescales the tensor to have a stable distribution. The three flavors differ in which axis they normalize over.
BatchNorm (Ioffe and Szegedy 2015) normalizes across the A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → axis. For a hidden activation (batch of examples, dimension ), it computes per dimension across the examples in the batch, then with learned . At Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →, it uses running statistics (exponential moving average of from training).
BatchNorm is great for CNNs on images: batches are large, batch statistics are stable, and the A built-in assumption that nudges a model toward certain kinds of patterns.Full glossary → matches. It is bad for transformers and RNNs — the sequence models, built from scratch in another chapter, that process a batch of A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → sequences where each token (each sequence position) carries its own One piece of information about an example that the model looks at when making a guess.Full glossary → vector: batch sizes can be small, sequence positions are variable, and you do not want positions to "see" each other through the normalization. It also has a training-vs-inference distribution mismatch (different statistics), and it breaks under distributed training without sync (each Where a piece of data lives and gets worked on: the main processor or the faster graphics chip.Full glossary → uses its local statistics).
LayerNorm (Ba et al. 2016) normalizes across the feature axis, per example. Same formula, but are computed per row of across the dimensions. No running stats; the same operation at train and inference time. Robust to batch size, robust to sequence length, plays well with distributed training. This is what every transformer uses.
RMSNorm (Zhang and Sennrich 2019) is LayerNorm without subtracting the mean: . The math is that LayerNorm's mean-centering is mostly redundant with the learned ; dropping it costs nothing in The share of guesses the model got right out of all its guesses.Full glossary → and saves a kernel pass. Llama, Mistral, and most 2023+ LLMs use RMSNorm.
nn.RMSNorm vs. from scratch
DL primitivermsn = nn.RMSNorm(normalized_shape=d, eps=1e-5)
y = rmsn(x) # normalizes over the last dim, learnable gamma scale, no mean subtractionclass RMSNorm(nn.Module):
def __init__(self, d: int, eps: float = 1e-5):
super().__init__()
self.gamma = nn.Parameter(torch.ones(d))
self.eps = eps
def forward(self, x):
rms = torch.sqrt((x * x).mean(dim=-1, keepdim=True) + self.eps)
return self.gamma * x / rmsfrom scratch: lab/solution.py: class RMSNorm
- 1
nn.RMSNorm(normalized_shape=d)self.gamma = nn.Parameter(torch.ones(d)) plus the forward that divides x by its RMS over the last dim - 2
the module's learnable weight (gamma), initialized to onesself.gamma = nn.Parameter(torch.ones(d)) - 3
internal RMS computation over normalized_shaperms = torch.sqrt((x * x).mean(dim=-1, keepdim=True) + self.eps) - 4
the scale-and-normalize outputreturn self.gamma * x / rms - 5
eps argument for numerical stability+ self.eps inside the sqrt
What the one call hides
- It normalizes over the trailing normalized_shape dims and never subtracts the mean (that is the whole point vs LayerNorm); the one-liner does not advertise this.
- The default gamma (weight) is initialized to all-ones, so at init RMSNorm is just an identity-scaled RMS rescale.
- Computation is upcast to float32 internally for the RMS reduction even in half precision, which the scratch version does not do.
- Default eps is 1e-6 in nn.RMSNorm; this scratch uses 1e-5 (matching the chapter's LayerNorm), so out of the box the two are not bit-identical unless you pass eps explicitly.
- Gotcha: nn.RMSNorm only exists in PyTorch 2.4+; on older versions the one-liner does not exist and you must hand-roll it.
- Gotcha: eps placement varies across implementations: this scratch (and nn.RMSNorm) put eps inside the sqrt (sqrt(mean(x^2)+eps)); the chapter draft's other RMSNorm adds eps to rms outside the sqrt, which is NOT bit-identical.
- Gotcha: Pass the feature dimension d as normalized_shape, not the batch size; getting the axis wrong silently normalizes the wrong dimension.
Use nn.RMSNorm (PyTorch 2.4+) or nn.LayerNorm in production for the float32-upcast and fused path; the from-scratch version is to prove RMSNorm is literally 'divide by root-mean-square over the feature axis, then scale by a learned vector' and to write custom-eps or fused variants the stock module does not expose.
On the job: You almost never reimplement the norm; you DO decide pre- vs post-norm placement, pick RMSNorm vs LayerNorm for the architecture, and occasionally write a fused/custom-eps variant for a kernel.
Where to put it. For a transformer block (Attn is the A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → sub-layer, the part of the block you build in another chapter): pre-norm (x = x + Attn(LayerNorm(x))) is the modern default. Post-norm (x = LayerNorm(x + Attn(x))) was the original transformer; it needs careful Starting training with tiny steps that grow for a little while before the main plan kicks in.Full glossary → or it diverges. Pre-norm trains stably; post-norm gives slightly better final loss in some studies, but the engineering price is rarely worth it.
For a CNN: Conv → BN → ReLU is the standard pattern. Putting BN before the activation (rather than after) is empirically better and removes the need for A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → in the conv layer.
FIG 11.3.4
Residual connections
A residual connection (also called a skip or shortcut connection — the "skip connections" from §1) wraps a sub-layer in an identity skip: where is the sub-layer's computation. He et al. (2015, ResNet) introduced them to enable training of 100+ layer CNNs. The mechanical reason they work: the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → of with respect to is , so even if , the gradient through the residual is . The signal cannot vanish through a residual.
That is the training story, and it is sufficient for getting deep networks to converge. another chapter then leverages it as the foundation of Working backward through a trained model to trace the exact steps that led to its answer.Full glossary →.
For a transformer block with pre-norm, the structure that becomes the centrepiece of another chapter:
class Block(nn.Module):
def __init__(self, d_model: int, n_heads: int):
super().__init__()
self.ln1 = nn.LayerNorm(d_model)
self.attn = MultiHeadAttention(d_model, n_heads)
self.ln2 = nn.LayerNorm(d_model)
self.ffn = FeedForward(d_model)
def forward(self, x):
x = x + self.attn(self.ln1(x)) # read, write
x = x + self.ffn(self.ln2(x)) # read, write
return xThe shape never changes. The same A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → flows through 12 (or 24, or 96) of these blocks and accumulates corrections at each one.
FIG 11.3.5
Dropout, weight decay, and label smoothing as one regularization stack
Three classic regularizers. Treating them as independent knobs is a mistake; they interact.
A training trick where the model randomly switches off some of its own pieces each pass, so it can't lean too hard on any one of them.Full glossary → (Srivastava et al. 2014) randomly zeros a fraction of activations during training. Modern implementations (PyTorch's nn.Dropout, d2l) use inverted dropout: during training, each surviving activation is rescaled to so that the expected value is unchanged (), and at Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → dropout does nothing — no nodes are dropped and no rescaling is applied. Standard rates: for transformers, to for MLPs and convnets. The mental model: dropout trains an exponential ensemble of subnets, then averages them at inference.
self.dropout = nn.Dropout(p=0.1)
def forward(self, x):
return self.dropout(self.fc(x))Gently nudging a model's weights toward smaller values to keep the model simpler and less likely to overfit.Full glossary → is L2 A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → on the weights, applied during the optimizer step. For SGD, the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → of is , so the update is . This shrinks weights toward zero at each step.
For Adam, the original implementation mixed weight decay into the gradient before the adaptive rescaling, which made it effectively weaker on parameters with large second moments. AdamW (Loshchilov and Hutter 2019) decouples weight decay from the gradient step. For LLM training, AdamW with weight_decay=0.1 is the canonical setting.
Crucial detail: weight decay is typically applied to weights but not biases or normalization parameters. ARENA's notes are explicit on this: "weight decay is often not applied to embeddings and layernorms in transformer models." The canonical pattern uses One of the model's internal numbers that gets adjusted as it learns.Full glossary → groups:
def configure_optimizer(model, weight_decay: float = 0.1, lr: float = 3e-4):
decay_params, nodecay_params = [], []
for n, p in model.named_parameters():
if p.dim() < 2 or "bias" in n or "norm" in n.lower() or "embed" in n.lower():
nodecay_params.append(p)
else:
decay_params.append(p)
return torch.optim.AdamW([
{"params": decay_params, "weight_decay": weight_decay},
{"params": nodecay_params, "weight_decay": 0.0},
], lr=lr, betas=(0.9, 0.95))Softening the training answers so 'definitely a cat' becomes 'almost certainly a cat, but not 100 percent.'Full glossary → (Szegedy et al. 2016) replaces the one-hot target with a soft target: for the true class, for the others. The effect is to discourage the model from producing overly-confident probabilities, which helps How well a model's stated confidence matches how often it's actually right.Full glossary → and a bit of How well the model handles brand-new examples it never studied.Full glossary →.
loss_fn = nn.CrossEntropyLoss(label_smoothing=0.1)How they interact:
- Dropout and weight decay both regularize; using them at full strength simultaneously typically over-regularizes. Standard combination: dropout +
weight_decay=0.1for transformers. - Label smoothing + weight decay + dropout is the Imagenet competition stack. Each one buys 0.5-1% test The share of guesses the model got right out of all its guesses.Full glossary → on its own; combined, maybe 1.5-2% (sub-additive).
- For tiny models on small data, prefer weight decay over dropout (less compute overhead, more interpretable).
- For large models with abundant data, you can often drop all three and rely on data alone. Llama-3 uses no dropout.
FIG 11.3.6
Optimizers in depth: SGD → Adam → AdamW → Lion → Muon
The optimizer is the rule that turns gradients into One of the model's internal numbers that gets adjusted as it learns.Full glossary → updates. The frontier has moved several times. The five that matter today:
SGD with Letting past adjustments build up speed so training keeps rolling in a steady direction instead of zig-zagging.Full glossary →. The update is , . The momentum term accumulates past gradients. Geometrically, it lets the optimizer build velocity through narrow valleys instead of zigzagging. Best for convnets when tuned well. Narrow LR sweet spot.
Adam. Per-parameter adaptive rescaling using first and second A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → moments with A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → correction. Each parameter effectively gets its own How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary →. Defaults: . The full update equations and derivation are in Ch 4 §12 (which owns optimizer math); the entry here covers only what changes when you scale Adam up to deep nets.
AdamW. Adam with decoupled Gently nudging a model's weights toward smaller values to keep the model simpler and less likely to overfit.Full glossary →. Original Adam folds A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary →-decay into the gradient before the adaptive rescaling, which means the effective A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → depends on the per-parameter variance estimate — that is wrong, decay should be uniform. AdamW applies weight decay as a separate step outside the gradient. For any model with weight decay, AdamW is the default. Karpathy: "use AdamW. Always."
Lion (Chen et al. 2023). Uses only the sign of the momentum: . The sign operation makes Lion memory-efficient (only one moment to track, not two) and somewhat faster. It works well on vision and small transformers. For large LLMs the results are mixed. Use it if you are memory-constrained.
Muon (Jordan et al. 2024). The current frontier for matrix-valued parameters (the 2D weights of linear layers). It orthogonalizes the momentum matrix via a Newton-Schulz iteration (a cheap iterative routine that pushes the matrix's singular values toward 1) before stepping. The intuition is that gradient-descent updates often align along a few dominant directions; orthogonalizing them spreads the update across all directions, accelerating training. Muon is used for the matrix params; biases, norms, and 1D params still use AdamW. State-of-the-art for transformer training in 2024-25.
# AdamW with parameter groups (the production default)
optimizer = torch.optim.AdamW([
{"params": decay_params, "weight_decay": 0.1},
{"params": nodecay_params, "weight_decay": 0.0},
], lr=3e-4, betas=(0.9, 0.95))
# Lion (third-party, https://github.com/lucidrains/lion-pytorch)
from lion_pytorch import Lion
optimizer = Lion(model.parameters(), lr=1e-4, weight_decay=0.1)
# Muon (third-party, https://github.com/KellerJordan/Muon)
from muon import Muon
optimizer_muon = Muon(matrix_params, lr=0.02, momentum=0.95)
optimizer_aw = torch.optim.AdamW(scalar_params, lr=3e-4)
# Use both: step both each iteration.Rule of thumb: if you do not know what to use, use AdamW. If you have measured AdamW and want to try something new, try Muon for matrix params. Lion is a niche choice.
FIG 11.3.7
Learning-rate schedules: warmup, cosine, 1cycle, restarts
The How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary → matters more than any other A setting you pick yourself before training starts, like the learning rate, batch size, or number of layers, which the model does not learn on its own.Full glossary →. A schedule changes over training, exploiting that you want different behavior at different points.
Linear Starting training with tiny steps that grow for a little while before the main plan kicks in.Full glossary →. For the first steps, ramp from 0 to the target value. Without warmup, the very first A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → step is too aggressive: the model's outputs are near-random, the loss is high, the gradient is huge, and a single big step can destabilize the parameters before adaptive optimizers have time to build their second moments. Standard is 1-10% of total training steps.
Cosine decay. After warmup, smoothly decay to a minimum (usually ) following a half-cosine: . The geometric reason: late in training, you want small steps to find the flat minimum's interior; early in training, you want large steps to make progress. Cosine is smooth and works almost universally. Default for LLM training.
1cycle policy (Smith 2017). Warmup to , then anneal below the starting LR. The trick is that Letting past adjustments build up speed so training keeps rolling in a steady direction instead of zig-zagging.Full glossary → is also scheduled (high momentum at low LR, low momentum at high LR). Often gives better results than warmup+cosine for fixed-budget training. Default for fastai-style training.
Warm restarts (Loshchilov and Hutter 2017). Periodic resets back to . The intuition is that each restart escapes a local minimum; the final One full trip through every example in your training set.Full glossary → settles into the best one. Useful for very long training runs.
The PyTorch API:
import torch.optim.lr_scheduler as sched
opt = torch.optim.AdamW(model.parameters(), lr=3e-4)
# Warmup + cosine (the most common modern combo, via SequentialLR)
warmup = sched.LinearLR(opt, start_factor=0.01, total_iters=1000)
cosine = sched.CosineAnnealingLR(opt, T_max=99_000, eta_min=3e-5)
scheduler = sched.SequentialLR(opt, schedulers=[warmup, cosine], milestones=[1000])
# Or all-in-one OneCycleLR
scheduler = sched.OneCycleLR(opt, max_lr=3e-4, total_steps=100_000,
pct_start=0.1, anneal_strategy="cos")
# In your training loop, step the scheduler EVERY iteration:
for batch in train_dl:
opt.zero_grad(); loss = compute_loss(batch); loss.backward(); opt.step()
scheduler.step()FIG 11.3.8
Gradient clipping
If a A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → norm spikes (a bad A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary →, a numerical issue, a When part of a model stops reacting to input because its output is already pushed to a hard limit.Full glossary →), the resulting update can move the parameters far enough to break training. Putting a cap on how big a single training adjustment can be so one wild step doesn't wreck progress.Full glossary → bounds the L2 norm of the gradient before the update.
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)Standard max_norm: 1.0 for transformers. The clipping is rare in normal training (most gradients are much smaller than 1.0); the value is in catching the outlier batches that would otherwise destroy training.
There are two related techniques. Gradient norm penalty adds to the loss; useful for adversarial training, less so for standard training. Per-One of the model's internal numbers that gets adjusted as it learns.Full glossary → clipping clips each parameter's gradient independently, which is faster but less principled.
FIG 11.3.9
Transfer learning and fine-tuning
Most production models start from a pretrained A saved snapshot of a model partway through training so you can stop and pick up later.Full glossary → — weights someone else already trained on a large dataset, saved and loaded with the same machinery you used for your own checkpoints in another chapter. By convention the backbone is the One piece of information about an example that the model looks at when making a guess.Full glossary →-extracting bulk of that network and the head is the small final layer that maps features to task outputs. Taking a model that already learned a lot of general skills and training it a bit more on your own specific data.Full glossary → is the process of adapting that checkpoint to a new task.
The two patterns:
Freezing a pretrained model and training only a small new piece on top to read out what it already knows.Full glossary →. Freeze all pretrained weights, train only a new classifier head. Cheap (no backward pass through the backbone), works well when the pretrained features are already good for the target task. Standard A simple reference method you compare against to see whether a fancier approach is actually worth it.Full glossary →.
for p in model.backbone.parameters():
p.requires_grad = False
model.head = nn.Linear(model.backbone.out_features, n_target_classes)
opt = torch.optim.AdamW(model.head.parameters(), lr=1e-3)Full fine-tune. Unfreeze everything, train with a small How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary →. Most expressive, best results when you have enough data to avoid When teaching a model a new task makes it forget the old task it already knew.Full glossary →.
opt = torch.optim.AdamW(model.parameters(), lr=1e-5, weight_decay=0.01)Discriminative learning rates (Howard and Ruder 2018). Use different learning rates for different layers. Lower rates for the early layers (general features that you do not want to overwrite) and higher rates for the later layers and head (task-specific features).
opt = torch.optim.AdamW([
{"params": model.backbone.early_layers.parameters(), "lr": 1e-6},
{"params": model.backbone.late_layers.parameters(), "lr": 1e-5},
{"params": model.head.parameters(), "lr": 1e-3},
])A cheap way to fine-tune by training small add-on pieces while leaving the big original model frozen.Full glossary → (low-rank adaptation, Hu et al. 2021) is the modern frontier for fine-tuning large models. Instead of updating the full A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → matrix , you add a low-rank correction: where , , . Only and are trained; is frozen. With , a 7B-One of the model's internal numbers that gets adjusted as it learns.Full glossary → model has only ~20M trainable parameters. Train on a single GPU; merge at Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →. Covered in detail in another chapter (efficient inference).
Catastrophic forgetting. When you fine-tune, you can erase the pretrained capabilities. Symptoms: pre-fine-tune the model on a benchmark, the benchmark scores collapse post-fine-tuning. Mitigations: use a low LR, train for few epochs, mix in some of the original pretraining data, or use LoRA (which leaves the pretrained weights unchanged).
FIG 11.3.10
Hyperparameter search with Optuna
You will tune at least: How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary →, Gently nudging a model's weights toward smaller values to keep the model simpler and less likely to overfit.Full glossary →, A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → size, and either A training trick where the model randomly switches off some of its own pieces each pass, so it can't lean too hard on any one of them.Full glossary → or some architectural choice. Manual grid search wastes compute. Optuna automates it.
The two pieces: an objective function that takes a Trial and returns the validation metric, and a study that runs trials.
import optuna
def objective(trial: optuna.Trial) -> float:
# Search space
lr = trial.suggest_float("lr", 1e-5, 1e-2, log=True)
weight_decay = trial.suggest_float("weight_decay", 1e-4, 1e-1, log=True)
hidden_dim = trial.suggest_categorical("hidden_dim", [128, 256, 512])
dropout = trial.suggest_float("dropout", 0.0, 0.5)
# Train
model = MLP(d_in=784, d_hidden=hidden_dim, d_out=10, dropout=dropout)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=weight_decay)
val_acc = train_model(model, opt, n_epochs=10)
# Report intermediate values for pruning
trial.report(val_acc, step=10)
if trial.should_prune():
raise optuna.TrialPruned()
return val_acc
study = optuna.create_study(direction="maximize",
sampler=optuna.samplers.TPESampler(),
pruner=optuna.pruners.MedianPruner())
study.optimize(objective, n_trials=100)
print(study.best_params)
print(study.best_value)Three knobs that matter:
Sampler. TPESampler (Tree-structured Parzen Estimator) for most problems. GridSampler for small categorical search spaces where you actually want exhaustive coverage. CmaEsSampler for high-dimensional continuous spaces.
Pruner. Stops unpromising trials early. MedianPruner is the default and works well. HyperbandPruner is more aggressive and saves compute on long-training setups.
Search space size. Use log=True for learning rates and A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → decays (the right scale is multiplicative). Use categorical for architectural choices (number of layers, activation type). Use int for things like hidden dimension (with step=64 or 128 if you want round numbers).
FIG 11.3.11
Debugging a training run
A training run can fail in many ways. The first 80% of debugging follows Karpathy's recipe with high reliability. Here is the checklist, in order:
Loss does not decrease at all (flat at ).
- Verify loss at init: it should be for -class classification. If it is something else, your output layer is mis-initialized or you have a bug in the A rule that turns one bad guess into a single number measuring how far off it was, where lower is better.Full glossary →.
- Print one A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → right before it enters the model. Are the inputs and labels what you expect? Normalization correct? Labels not flipped?
- Overfit one batch of 4 examples. If you cannot drive loss to zero on 4 examples with a model that has enough capacity, you have a bug. Find it before Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → up.
- Check that
optimizer.zero_gradis being called. - Check that
requires_grad=Trueis set on parameters (it is by default fornn.Parameter, but if you froze something during Reusing a model that already learned general skills as a head start, then retraining just a little of it for a new, related task.Full glossary →, did you unfreeze the right thing?).
Loss is decreasing but very slowly.
- Is the How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary → too small? Try a 10× LR.
- Are you using
optim.SGDwithout Letting past adjustments build up speed so training keeps rolling in a steady direction instead of zig-zagging.Full glossary →? Adam usually trains faster early. - Is your data normalized? Inputs with very different scales train slowly.
- Are gradients vanishing at early layers? Log per-layer A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → norms.
Loss spikes to NaN.
- Gradient explosion. Add
clip_grad_norm_withmax_norm=1.0. - Mixed-Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary → overflow. Check
GradScaleris enabled. Or switch to bfloat16. - Numerical instability in the loss.
log(0)or1/0. Addepsto denominators. - Bad data: an outlier example with extreme values. Print the batch right before the NaN.
Loss decreases but val The share of guesses the model got right out of all its guesses.Full glossary → does not improve.
- You are When a model memorizes the quirks and flukes of its study examples instead of the real pattern, so it flops on anything new.Full glossary →. Increase A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → (A training trick where the model randomly switches off some of its own pieces each pass, so it can't lean too hard on any one of them.Full glossary →, Gently nudging a model's weights toward smaller values to keep the model simpler and less likely to overfit.Full glossary →, or more data).
- Val/train data distribution mismatch. Plot both distributions, look for differences.
- Bug in eval code. Run train and val on the same data — they should give the same loss.
Loss diverges late in training.
- LR schedule has not annealed. Lower the final LR.
- The optimizer's second moment estimate has accumulated bad statistics. Reset the optimizer.
- When teaching a model a new task makes it forget the old task it already knew.Full glossary → during Taking a model that already learned a lot of general skills and training it a bit more on your own specific data.Full glossary →. Lower the LR or stop earlier.
This checklist is mostly a transcription of Karpathy's recipe (24-founder-blogs/karpathy-recipe), with additions from the cs336 lecture 2 troubleshooting section and the ARENA optimization notebook. Internalize it before you have to debug under time pressure.
FIG 11.4 · Safety lens · this chapter
Training deep networks well requires a lot of bespoke knobs. Each knob is a place for safety-relevant subtle failures to hide. Three that are present in the techniques you just learned, not speculation.
A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → as a benchmark-gaming surface. Gently nudging a model's weights toward smaller values to keep the model simpler and less likely to overfit.Full glossary →, Softening the training answers so 'definitely a cat' becomes 'almost certainly a cat, but not 100 percent.'Full glossary →, and A training trick where the model randomly switches off some of its own pieces each pass, so it can't lean too hard on any one of them.Full glossary → all improve val The share of guesses the model got right out of all its guesses.Full glossary → by 0.5-2%. They do not, in general, improve out-of-distribution robustness or alignment-relevant properties. A model that gets 1% more accuracy on a held-out test split via aggressive weight decay can simultaneously be worse at refusing harmful prompts, because the same regularizer is shrinking the safety-relevant features. This shows up empirically in the literature on RLHF training: standard A setting you pick yourself before training starts, like the learning rate, batch size, or number of layers, which the model does not learn on its own.Full glossary → sweeps that optimize benchmark performance often produce models with subtly degraded refusal behavior, which only shows up in dedicated safety evals. The fix is to evaluate safety properties as part of HP search, not just downstream task accuracy. See 18-lilian-weng/2024-11-28-reward-hacking §hacking-rlhf-of-llms and 22-anthropic-recent/2024-scaling-monosemanticity §refusal-features for the framing.
Initialization as an interpretability surface. The features a network learns depend on the initialization. Two models trained from different seeds with the same data and architecture can decompose the The main running tally of information that flows through a deep model, with each layer reading from it and adding its bit back in.Full glossary → into entirely different features. Working backward through a trained model to trace the exact steps that led to its answer.Full glossary → findings are sometimes A starting number that makes a program's 'random' choices come out the same every time.Full glossary →-specific: "the IOI circuit lives in heads 5.3 and 7.10" is a statement about that initialization, not about transformers in general. For safety-relevant claims — "we found the refusal One piece of information about an example that the model looks at when making a guess.Full glossary → in this model" — the implication is that you should verify across multiple seeds. The Anthropic interpretability papers do this rigorously; less-careful work often does not. See 22-anthropic-recent/2023-monosemantic-features-index §universality and 22-anthropic-recent/2024-crosscoders-index §model-diffing.
Hyperparameter search as a leakage One of several stacked grids of numbers in an image, each tracking a different kind of pattern.Full glossary →. Optuna and other HP search tools choose hyperparameters that maximize a validation metric. If the same A separate batch of examples you check the model against while you're still tinkering, to see how it's doing.Full glossary → is used downstream for "did the model pass our safety eval", you are effectively training on the eval. The HP search has already memorized which configurations score well on the eval; the released model is the one with the best memorization, not the one that generalizes. This is the same eval-contamination problem that haunts LLM benchmarks but it shows up at the HP-search level. The fix is a separate held-out safety eval that is never seen during HP search, and a discipline of running it only at the end. See 23-eval-science (another chapter forward reference) and 26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications §training-data-poisoning for the broader context.
Habits to adopt:
- Treat your HP search budget as part of your eval pipeline. If you ran 100 trials, your "best val accuracy" overstates true How well the model handles brand-new examples it never studied.Full glossary → by a known amount; held-out test is the antidote.
- Run safety-relevant evals across multiple seeds. A finding that holds for seed=42 and breaks for seed=0 is not a finding.
- Log per-layer A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → norms from step 1. This single piece of telemetry catches more silent training failures than anything else. The Hero widget in this chapter shows you why.
FIG 11.5 · Under the hood
The library call, and the lines it hides
You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.
Warmup + cosine LR (SequentialLR) vs. closed form
DL gluewarmup = sched.LinearLR(opt, start_factor=min_lr/base_lr, total_iters=warmup_steps)
cosine = sched.CosineAnnealingLR(opt, T_max=total_steps-warmup_steps, eta_min=min_lr)
scheduler = sched.SequentialLR(opt, [warmup, cosine], milestones=[warmup_steps])
# call scheduler.step() once per ITERATION, after optimizer.step()def warmup_cosine_lr(step, warmup_steps, total_steps, base_lr, min_lr):
if step < warmup_steps:
return min_lr + (base_lr - min_lr) * step / max(1, warmup_steps)
progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
progress = min(1.0, max(0.0, progress))
return min_lr + 0.5 * (base_lr - min_lr) * (1 + math.cos(math.pi * progress))
# in train_epoch:
lr = warmup_cosine_lr(step, warmup_steps, total_steps, base_lr, min_lr)
for g in opt.param_groups:
g["lr"] = lrfrom scratch: lab/solution.py: warmup_cosine_lr
- 1
LinearLR(start_factor=min_lr/base_lr, total_iters=warmup_steps)the if step < warmup_steps branch: linear ramp from min_lr to base_lr - 2
CosineAnnealingLR(T_max=total_steps-warmup_steps, eta_min=min_lr)the else branch: min_lr + 0.5*(base_lr-min_lr)*(1+cos(pi*progress)) - 3
SequentialLR(..., milestones=[warmup_steps])the if/else split at step == warmup_steps handing off ramp to cosine - 4
scheduler.step() mutating opt.param_groups[i]['lr']for g in opt.param_groups: g['lr'] = lr - 5
eta_min (cosine floor)min_lr as the additive floor in both branches
What the one call hides
- The schedulers mutate optimizer.param_groups[i]['lr'] in place when you call .step(); that is the exact write the scratch loop does explicitly.
- CosineAnnealingLR's T_max is measured in scheduler.step() calls; step per epoch instead of per iteration and you silently get the wrong period.
- LinearLR ramps a MULTIPLICATIVE start_factor of the optimizer's base lr, not an absolute min_lr; matching this scratch's absolute floor requires start_factor=min_lr/base_lr.
- SequentialLR's milestone semantics shifted across PyTorch versions (an off-by-one in when the cosine engages), so the handoff index can differ by one from this scratch even though the endpoints agree.
- Gotcha: The classic bug the draft calls out: scheduler.step() once per epoch instead of per iteration, so a 1000-step warmup finishes at epoch 1000.
- Gotcha: CosineAnnealingLR keeps oscillating back up after T_max; for a one-shot decay-to-floor you must stop or cap progress (this scratch clamps progress to <=1.0).
- Gotcha: Stepping the scheduler before the first optimizer.step() triggers a PyTorch warning and can shift the schedule by one step.
In production wire LinearLR+CosineAnnealingLR via SequentialLR (or HF get_cosine_schedule_with_warmup) so you inherit checkpoint/resume support; the from-scratch closed form is for understanding the schedule is two formulas and for exotic shapes (WSD/trapezoidal) the stock schedulers do not ship.
On the job: You write the LR schedule (and its per-iteration step + resume logic) by hand or wire the stock schedulers together yourself; nobody ships you a turnkey warmup+cosine for your exact step budget.
clip_grad_norm_ vs. global-norm rescale
DL gluetorch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# call AFTER loss.backward(), BEFORE optimizer.step()# the equivalent hand-rolled global-norm clip:
params = [p for p in model.parameters() if p.grad is not None]
total_norm = torch.sqrt(sum(p.grad.pow(2).sum() for p in params))
clip_coef = max_norm / (total_norm + 1e-6)
if clip_coef < 1.0:
for p in params:
p.grad.mul_(clip_coef)
# solution.py uses the library call in the loop:
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=clip_norm)
opt.step()from scratch: lab/solution.py: train_epoch (clip step) + draft.md §8
- 1
clip_grad_norm_(params, max_norm)compute total_norm over all grads, then scale every grad by max_norm/total_norm if it exceeds max_norm - 2
the returned total_norm (pre-clip)total_norm = sqrt(sum(p.grad^2)) before scaling - 3
the global (across-all-params) norm semanticsone shared clip_coef applied to every p.grad, not per-tensor - 4
in-place modification of .gradp.grad.mul_(clip_coef) - 5
no-op when norm <= max_normthe if clip_coef < 1.0 guard
What the one call hides
- It computes ONE global L2 norm across the concatenation of all gradients, not a per-parameter clip; the update DIRECTION is preserved, only its length is capped.
- It returns the pre-clip total norm (the value you should log), which the assessment's assertion relies on.
- Default norm_type is 2.0 (Euclidean); it can do inf-norm or others, and it handles grads spread across devices/dtypes.
- It silently skips parameters whose .grad is None, so frozen/unused params do not poison the norm.
- Gotcha: Must be called after loss.backward() and before optimizer.step(); clipping after the step does nothing.
- Gotcha: With mixed-precision GradScaler you must scaler.unscale_(opt) first, otherwise you clip the scaled (inflated) gradients.
- Gotcha: clip_grad_norm_ (global) is not clip_grad_value_ (per-element); swapping them changes behavior on outlier batches.
Prefer torch.nn.utils.clip_grad_norm_ in production: one line, returns the norm you want to log, and handles multi-device/dtype edge cases; the from-scratch version exists to see that 'clip to max_norm' is just rescaling all grads by max_norm/total_norm when the global norm is too big.
On the job: You write the call site (after backward, before step, with the unscale dance under AMP) and log the returned pre-clip norm to catch spikes; the rescale math itself you never reimplement.
AdamW decay/no-decay param groups
DL glueoptimizer = torch.optim.AdamW([
{"params": decay_params, "weight_decay": weight_decay},
{"params": nodecay_params, "weight_decay": 0.0},
], lr=lr, betas=(0.9, 0.95))def configure_adamw(model, lr, weight_decay):
decay_params, nodecay_params = [], []
for n, p in model.named_parameters():
if not p.requires_grad:
continue
if p.dim() < 2 or any(k in n.lower() for k in ("bias", "norm", "embed", "ln", "gamma")):
nodecay_params.append(p)
else:
decay_params.append(p)
return torch.optim.AdamW(
[{"params": decay_params, "weight_decay": weight_decay},
{"params": nodecay_params, "weight_decay": 0.0}],
lr=lr,
)from scratch: lab/solution.py: configure_adamw
- 1
torch.optim.AdamW(param_groups, lr=...)the return torch.optim.AdamW([...]) with two groups - 2
per-group weight_decay key{'weight_decay': weight_decay} vs {'weight_decay': 0.0} - 3
the no-decay group for 1D / norm / embed paramsthe if p.dim() < 2 or 'bias'/'norm'/'embed'/'ln'/'gamma' in name branch - 4
decoupled (AdamW-style, not L2-in-gradient) decayrelying on AdamW rather than folding lambda*W into the gradient
What the one call hides
- This is NOT a from-scratch optimizer: solution.py calls torch.optim.AdamW; only the parameter-group SPLIT is hand-written. The Adam moments, bias correction, and the theta <- (1 - lr*lambda)*theta decoupled step all live inside the library.
- AdamW's betas default to (0.9, 0.999) and eps to 1e-8; the draft's production call overrides betas to (0.9, 0.95) for LLM training, but this bare configure_adamw leaves betas at (0.9, 0.999).
- The string-matching classification is a heuristic; it depends on parameter names containing 'bias'/'norm'/'gamma' etc. and will misclassify weights that do not follow that naming.
- weight_decay in AdamW is the decoupled coefficient, not L2 added to the loss; effective shrinkage is lr*weight_decay per step.
- Gotcha: If you call plain Adam (not AdamW) with weight_decay, decay is coupled into the gradient and is effectively weaker on high-variance params - a silent, common mistake.
- Gotcha: Forgetting the no-decay group and decaying LayerNorm gammas and biases measurably hurts; a naming scheme the heuristic misses sends weights into the wrong group.
- Gotcha: betas left at (0.9, 0.999) can be unstable at LLM scale where (0.9, 0.95) is standard; the difference is easy to miss.
AdamW is a strong default for many transformer workloads, while the best optimizer remains task- and regime-dependent. The practical custom work here is the decay/no-decay parameter-group split, so this pair teaches correct library configuration rather than re-implementing the optimizer.
On the job: You write exactly this param-group splitter (and pick betas/wd/lr) on every real training run; the optimizer math is the library's, the partition policy is the part that is yours and that bites if wrong.
FIG 11.6 · Chapter notebook
Build this chapter with your own hands
A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.
What you'll build
- A depth dial that makes the same MLP train cleanly at 4 layers and die silently at 40, with the per-layer gradient-norm log that explains why.
- Xavier and Kaiming initialization derived from one variance equation, implemented from scratch, and checked against torch.nn.init to the third decimal.
- LayerNorm and RMSNorm built by hand and verified element-for-element against torch.nn, plus the BatchNorm train-vs-eval footgun shown on purpose.
- The four diagnostic plots from Karpathy's recipe (activation histograms, saturation fraction, gradient histograms, the update-to-data ratio) wired onto a real character-level model trained on names.txt.
- A learning rate you blow up to NaN on purpose, diagnosed from the loss and gradient norms and repaired, plus a measurement of how residual connections keep a 40-layer net's input-layer gradient from vanishing.
- A capstone where you backpropagate a two-layer net by hand and cmp() every gradient against autograd until all of them read [ ok ].
~4 min on CPU · 100 cells · 12 checked exercises · runs in Colab
FIG 11.7 · Going further
24-founder-blogs/karpathy-recipere-read it every 6 months. The most useful 6000 words on training in existence.
14-arena-notebooks/chapter0-part3-optimizationARENA's 136KB optimization notebook. Goes deeper on the SGD/Adam math than this chapter and includes a small Optuna study.
04-stanford/cs336-lecture_02CS336 lecture 2 (PyTorch + resource accounting). The training-loop section is the cleanest production-style write-up.
08-geron-notebooks/11_training_deep_neural_networksGéron's full chapter on this material. More breadth than depth, useful as a second view.
18-lilian-weng/2021-09-25-train-largeLilian Weng's post on training at large scale. Covers FSDP, ZeRO, tensor parallelism, mixed precision in more depth than this chapter.
24-founder-blogs/dettmers-*Tim Dettmers' blog on optimizer + hardware co-design. Once you know AdamW, this is what you read.
16-d2l-sections/chapter_optimization__lr-schedulerand the surrounding optimization chapter — D2L's comprehensive treatment of the optimization side. Heavier on math, lighter on practice.
FIG 11.8 · What this enables
Chapters you can now read, with the connecting idea written out.
the training discipline from this chapter (init, normalization, residuals, AdamW, warmup-cosine, clipping) is the substrate for every modern CNN. ResNet is residual blocks + BatchNorm.
when you start training sequence models, the LR-warmup-or-it-diverges fact is no longer an abstract concern. This chapter is why.
every fix in this chapter shows up. Pre-norm transformers + Kaiming/Xavier-scaled init + AdamW + warmup-cosine + gradient clipping is the recipe.
PPO is a deep network trained with weird losses and tight stability constraints. Without this chapter, those constraints look mysterious; with it, they look like the standard stack with an asterisk.
FIG 11.9 · 33 sources
- 04-stanford/cs336-lecture_02
- 06-hf-alignment-handbook
- 08-geron-notebooks/11_training_deep_neural_networks
- 08-geron-notebooks/extra_autodiff
- 08-geron-notebooks/extra_ann_architectures
- 08-geron-notebooks/extra_gradient_descent_comparison
- 09-udl-book/UDL-Answer-Booklet
- 12-karpathy-code/nanoGPT-master-train
- 13-fastbook/05_pet_breeds
- 13-fastbook/16_accel_sgd
- 14-arena-notebooks/chapter0-part3-optimization
- 14-arena-notebooks/chapter1-part4-attribution
- 16-d2l-sections/chapter_convolutional-modern__batch-norm
- 16-d2l-sections/chapter_convolutional-modern__resnet
- 16-d2l-sections/chapter_hyperparameter-optimization__hyperopt-api
- 16-d2l-sections/chapter_multilayer-perceptrons__dropout
- 16-d2l-sections/chapter_multilayer-perceptrons__numerical-stability-and-init
- 16-d2l-sections/chapter_multilayer-perceptrons__weight-decay
- 16-d2l-sections/chapter_optimization__adam
- 16-d2l-sections/chapter_optimization__lr-scheduler
- 16-d2l-sections/chapter_optimization__momentum
- 18-lilian-weng/2017-08-01-interpretation
- 18-lilian-weng/2018-06-24-attention
- 18-lilian-weng/2021-09-25-train-large
- 18-lilian-weng/2023-01-27-the-transformer-family-v2
- 19-nanda-blog/mechanistic-interpretability-an-intuitive-explanation
- 22-anthropic-recent/2021-mathematical-framework
- 22-anthropic-recent/2024-scaling-monosemanticity
- 24-founder-blogs/karpathy-recipe
- 24-founder-blogs/dettmers-bitsandbytes-llm-int8
- 25-alignment-canon/agi-safety-from-first-principles
- 26-pentest-redteam/owasp-llm-top-10
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-optimization-tutorial-html