Ch. 11

Training Deep Networks

Init schemes, BatchNorm vs LayerNorm vs RMSNorm, AdamW vs Lion vs Muon, LR schedules, Karpathy's recipe.

initnormalizationoptimizersschedules

FIG 11 · Explainer video


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 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--rescale so that even slow-moving directions update meaningfully. Learning-rate 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

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 in 30 seconds. Train a 30-layer MLP with the same setup: loss stuck at log102.30\log 10 \approx 2.30, 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 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 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 λ\lambda, the gradient norm shrinks (or grows) by λ30\lambda^{30}. For λ=0.9\lambda = 0.9, that's λ300.04\lambda^{30} \approx 0.04. For λ=1.1\lambda = 1.1, that's λ3017\lambda^{30} \approx 17. 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:

  1. Activations that do not saturate ( and descendants).
  2. Initialization that keeps the per-layer Jacobian's expected singular values near 1 (Xavier, Kaiming, μP).
  3. Normalization that re-centers the activation distribution at every layer (BatchNorm, LayerNorm, RMSNorm).
  4. Residual connections that give the gradient a shortcut path independent of the layer's Jacobian (ResNet, transformer blocks).
  5. Adaptive optimizers that rescale the update per- 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 σ2\sigma^2, then for input xRdinx \in \mathbb{R}^{d_{in}} with components of unit variance, the output y=Wxy = Wx has components of variance dinσ2d_{in} \sigma^2. To preserve variance across the layer, set σ2=1/din\sigma^2 = 1/d_{in}.

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: σ2=2/(din+dout)\sigma^2 = 2/(d_{in} + d_{out}). It assumes the activation has unit derivative near zero, which is true for tanh but not for . Use it with tanh or .

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 2\sqrt{2}: σ2=2/din\sigma^2 = 2/d_{in}. 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- learning-rate depends on the layer's 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 glue
LIBRARY
def 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)
FROM SCRATCH
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. 1nn.init.kaiming_normal_(W, nonlinearity='relu') sigma = sqrt(2.0 / d_in); W = standard_normal(shape) * sigma
  2. 2the gain=sqrt(2) for relu baked into kaiming the factor of 2 in sqrt(2.0 / d_in)
  3. 3fan_in computed from the weight shape the d_in in the denominator
  4. 4nn.init.zeros_(m.bias) self.b = np.zeros(d_out)
  5. 5model.apply(init_weights) walking every submodule for 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 , 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 axis. For a hidden activation hRn×dh \in \mathbb{R}^{n \times d} (batch of nn examples, dimension dd), it computes μ,σ\mu, \sigma per dimension dd across the nn examples in the batch, then h^=γ(hμ)/σ+β\hat{h} = \gamma (h - \mu) / \sigma + \beta with learned γ,β\gamma, \beta. At , it uses running statistics (exponential moving average of μ,σ\mu, \sigma from training).

BatchNorm is great for CNNs on images: batches are large, batch statistics are stable, and the matches. It is bad for transformers and RNNs — the sequence models, built from scratch in another chapter, that process a batch of sequences where each token (each sequence position) carries its own 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 uses its local statistics).

LayerNorm (Ba et al. 2016) normalizes across the feature axis, per example. Same formula, but μ,σ\mu, \sigma are computed per row of hh across the dd 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: h^=γh/mean(h2)+ϵ\hat{h} = \gamma \cdot h / \sqrt{\text{mean}(h^2) + \epsilon}. The math is that LayerNorm's mean-centering is mostly redundant with the learned β\beta; dropping it costs nothing in and saves a kernel pass. Llama, Mistral, and most 2023+ LLMs use RMSNorm.

nn.RMSNorm vs. from scratch

DL primitive
LIBRARY
rmsn = nn.RMSNorm(normalized_shape=d, eps=1e-5)
y = rmsn(x)  # normalizes over the last dim, learnable gamma scale, no mean subtraction
FROM SCRATCH
class 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 / rms

from scratch: lab/solution.py: class RMSNorm

  1. 1nn.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. 2the module's learnable weight (gamma), initialized to ones self.gamma = nn.Parameter(torch.ones(d))
  3. 3internal RMS computation over normalized_shape rms = torch.sqrt((x * x).mean(dim=-1, keepdim=True) + self.eps)
  4. 4the scale-and-normalize output return self.gamma * x / rms
  5. 5eps 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 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 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 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: y=x+F(x)y = x + F(x) where FF 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 of y=x+F(x)y = x + F(x) with respect to xx is 1+F(x)1 + F'(x), so even if F(x)0F'(x) \to 0, the gradient through the residual is 1\geq 1. 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 .

For a transformer block with pre-norm, the structure that becomes the centrepiece of another chapter:

Python
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 x

The shape never changes. The same 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.

(Srivastava et al. 2014) randomly zeros a fraction pp of activations during training. Modern implementations (PyTorch's nn.Dropout, d2l) use inverted dropout: during training, each surviving activation hh is rescaled to h/(1p)h/(1-p) so that the expected value is unchanged (E[h]=hE[h'] = h), and at dropout does nothing — no nodes are dropped and no rescaling is applied. Standard rates: p=0.1p=0.1 for transformers, p=0.2p=0.2 to 0.50.5 for MLPs and convnets. The mental model: dropout trains an exponential ensemble of subnets, then averages them at inference.

Python
self.dropout = nn.Dropout(p=0.1)
def forward(self, x):
    return self.dropout(self.fc(x))

is L2 on the weights, applied during the optimizer step. For SGD, the of λ2W2\frac{\lambda}{2}\|W\|^2 is λW\lambda W, so the update is WWη(g+λW)=(1ηλ)WηgW \leftarrow W - \eta (g + \lambda W) = (1 - \eta \lambda) W - \eta g. 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 groups:

Python
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))

(Szegedy et al. 2016) replaces the one-hot target with a soft target: 0.90.9 for the true class, 0.1/(K1)0.1 / (K-1) for the others. The effect is to discourage the model from producing overly-confident probabilities, which helps and a bit of .

Python
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: p=0.1p=0.1 dropout + weight_decay=0.1 for transformers.
  • Label smoothing + weight decay + dropout is the Imagenet competition stack. Each one buys 0.5-1% test 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 updates. The frontier has moved several times. The five that matter today:

SGD with . The update is vt=μvt1+gtv_t = \mu v_{t-1} + g_t, θt=θt1ηvt\theta_t = \theta_{t-1} - \eta v_t. The momentum term μ0.9\mu \approx 0.9 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 moments with correction. Each parameter effectively gets its own . Defaults: β1=0.9,β2=0.999,ϵ=108,η=3×104\beta_1=0.9, \beta_2=0.999, \epsilon=10^{-8}, \eta=3 \times 10^{-4}. 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 . Original Adam folds -decay into the gradient before the adaptive rescaling, which means the effective depends on the per-parameter variance estimate — that is wrong, decay should be uniform. AdamW applies weight decay as a separate θ(1ηλ)θ\theta \leftarrow (1 - \eta \lambda) \theta 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: θt=θt1ηsign(β1vt1+(1β1)gt)\theta_t = \theta_{t-1} - \eta \cdot \text{sign}(\beta_1 v_{t-1} + (1-\beta_1) g_t). 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.

Python
# 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 η\eta matters more than any other . A schedule changes η\eta over training, exploiting that you want different behavior at different points.

Linear . For the first TwT_w steps, ramp η\eta from 0 to the target value. Without warmup, the very first 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 TwT_w is 1-10% of total training steps.

Cosine decay. After warmup, smoothly decay η\eta to a minimum (usually 0.1×ηmax0.1 \times \eta_{max}) following a half-cosine: η(t)=ηmin+0.5(ηmaxηmin)(1+cos(πt/T))\eta(t) = \eta_{min} + 0.5 (\eta_{max} - \eta_{min})(1 + \cos(\pi t / T)). 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 ηmax\eta_{max}, then anneal below the starting LR. The trick is that 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 ηmax\eta_{max}. The intuition is that each restart escapes a local minimum; the final settles into the best one. Useful for very long training runs.

The PyTorch API:

Python
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 norm spikes (a bad , a numerical issue, a ), the resulting update can move the parameters far enough to break training. bounds the L2 norm of the gradient before the update.

Python
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 λL2\lambda \|\nabla L\|^2 to the loss; useful for adversarial training, less so for standard training. Per- 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 — 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 -extracting bulk of that network and the head is the small final layer that maps features to task outputs. is the process of adapting that checkpoint to a new task.

The two patterns:

. 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 .

Python
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 . Most expressive, best results when you have enough data to avoid .

Python
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).

Python
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},
])

(low-rank adaptation, Hu et al. 2021) is the modern frontier for fine-tuning large models. Instead of updating the full matrix WW, you add a low-rank correction: W=W+ABW' = W + AB where ARd×rA \in \mathbb{R}^{d \times r}, BRr×dB \in \mathbb{R}^{r \times d}, rdr \ll d. Only AA and BB are trained; WW is frozen. With r=8r=8, a 7B- model has only ~20M trainable parameters. Train on a single GPU; merge at . 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: , , size, and either 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.

Python
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 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 logK\log K).

  1. Verify loss at init: it should be logK\log K for KK-class classification. If it is something else, your output layer is mis-initialized or you have a bug in the .
  2. Print one right before it enters the model. Are the inputs and labels what you expect? Normalization correct? Labels not flipped?
  3. 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 up.
  4. Check that optimizer.zero_grad is being called.
  5. Check that requires_grad=True is set on parameters (it is by default for nn.Parameter, but if you froze something during , did you unfreeze the right thing?).

Loss is decreasing but very slowly.

  1. Is the too small? Try a 10× LR.
  2. Are you using optim.SGD without ? Adam usually trains faster early.
  3. Is your data normalized? Inputs with very different scales train slowly.
  4. Are gradients vanishing at early layers? Log per-layer norms.

Loss spikes to NaN.

  1. Gradient explosion. Add clip_grad_norm_ with max_norm=1.0.
  2. Mixed- overflow. Check GradScaler is enabled. Or switch to bfloat16.
  3. Numerical instability in the loss. log(0) or 1/0. Add eps to denominators.
  4. Bad data: an outlier example with extreme values. Print the batch right before the NaN.

Loss decreases but val does not improve.

  1. You are . Increase (, , or more data).
  2. Val/train data distribution mismatch. Plot both distributions, look for differences.
  3. Bug in eval code. Run train and val on the same data — they should give the same loss.

Loss diverges late in training.

  1. LR schedule has not annealed. Lower the final LR.
  2. The optimizer's second moment estimate has accumulated bad statistics. Reset the optimizer.
  3. during . 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.

as a benchmark-gaming surface. , , and all improve val 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 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 into entirely different features. findings are sometimes -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 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 . Optuna and other HP search tools choose hyperparameters that maximize a validation metric. If the same 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 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 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 glue
LIBRARY
warmup = 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()
FROM SCRATCH
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"] = lr

from scratch: lab/solution.py: warmup_cosine_lr

  1. 1LinearLR(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. 2CosineAnnealingLR(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. 3SequentialLR(..., milestones=[warmup_steps]) the if/else split at step == warmup_steps handing off ramp to cosine
  4. 4scheduler.step() mutating opt.param_groups[i]['lr'] for g in opt.param_groups: g['lr'] = lr
  5. 5eta_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 glue
LIBRARY
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# call AFTER loss.backward(), BEFORE optimizer.step()
FROM SCRATCH
# 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. 1clip_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. 2the returned total_norm (pre-clip) total_norm = sqrt(sum(p.grad^2)) before scaling
  3. 3the global (across-all-params) norm semantics one shared clip_coef applied to every p.grad, not per-tensor
  4. 4in-place modification of .grad p.grad.mul_(clip_coef)
  5. 5no-op when norm <= max_norm the 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 glue
LIBRARY
optimizer = torch.optim.AdamW([
    {"params": decay_params,   "weight_decay": weight_decay},
    {"params": nodecay_params, "weight_decay": 0.0},
], lr=lr, betas=(0.9, 0.95))
FROM SCRATCH
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. 1torch.optim.AdamW(param_groups, lr=...) the return torch.optim.AdamW([...]) with two groups
  2. 2per-group weight_decay key {'weight_decay': weight_decay} vs {'weight_decay': 0.0}
  3. 3the no-decay group for 1D / norm / embed params the if p.dim() < 2 or 'bias'/'norm'/'embed'/'ln'/'gamma' in name branch
  4. 4decoupled (AdamW-style, not L2-in-gradient) decay relying 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-recipe

    re-read it every 6 months. The most useful 6000 words on training in existence.

  • 14-arena-notebooks/chapter0-part3-optimization

    ARENA's 136KB optimization notebook. Goes deeper on the SGD/Adam math than this chapter and includes a small Optuna study.

  • 04-stanford/cs336-lecture_02

    CS336 lecture 2 (PyTorch + resource accounting). The training-loop section is the cleanest production-style write-up.

  • 08-geron-notebooks/11_training_deep_neural_networks

    Géron's full chapter on this material. More breadth than depth, useful as a second view.

  • 18-lilian-weng/2021-09-25-train-large

    Lilian 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-scheduler and 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
  1. 04-stanford/cs336-lecture_02
  2. 06-hf-alignment-handbook
  3. 08-geron-notebooks/11_training_deep_neural_networks
  4. 08-geron-notebooks/extra_autodiff
  5. 08-geron-notebooks/extra_ann_architectures
  6. 08-geron-notebooks/extra_gradient_descent_comparison
  7. 09-udl-book/UDL-Answer-Booklet
  8. 12-karpathy-code/nanoGPT-master-train
  9. 13-fastbook/05_pet_breeds
  10. 13-fastbook/16_accel_sgd
  11. 14-arena-notebooks/chapter0-part3-optimization
  12. 14-arena-notebooks/chapter1-part4-attribution
  13. 16-d2l-sections/chapter_convolutional-modern__batch-norm
  14. 16-d2l-sections/chapter_convolutional-modern__resnet
  15. 16-d2l-sections/chapter_hyperparameter-optimization__hyperopt-api
  16. 16-d2l-sections/chapter_multilayer-perceptrons__dropout
  17. 16-d2l-sections/chapter_multilayer-perceptrons__numerical-stability-and-init
  18. 16-d2l-sections/chapter_multilayer-perceptrons__weight-decay
  19. 16-d2l-sections/chapter_optimization__adam
  20. 16-d2l-sections/chapter_optimization__lr-scheduler
  21. 16-d2l-sections/chapter_optimization__momentum
  22. 18-lilian-weng/2017-08-01-interpretation
  23. 18-lilian-weng/2018-06-24-attention
  24. 18-lilian-weng/2021-09-25-train-large
  25. 18-lilian-weng/2023-01-27-the-transformer-family-v2
  26. 19-nanda-blog/mechanistic-interpretability-an-intuitive-explanation
  27. 22-anthropic-recent/2021-mathematical-framework
  28. 22-anthropic-recent/2024-scaling-monosemanticity
  29. 24-founder-blogs/karpathy-recipe
  30. 24-founder-blogs/dettmers-bitsandbytes-llm-int8
  31. 25-alignment-canon/agi-safety-from-first-principles
  32. 26-pentest-redteam/owasp-llm-top-10
  33. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-optimization-tutorial-html