PROMETHEUS OS
NATIVE AI OPERATING SYSTEM
[INIT] Prometheus OS loading...
[CORE] Neural interface initializing...
[AI] LLM router connecting...
[MEM] Memory graph loading...
[AGT] Agent chains on standby...
[SEC] Security protocols active...
[NET] Provider mesh established...
[UI] Workspace rendering...
[DONE] All systems nominal.
Welcome to PROMETHEUS OS.
READY · OFFLINE · SECURE · PRIVATE
🎨 THEME ENGINE
PROMETHEUS OS · 23 THEMES
Trial version: Dark + Light themes only.
Full theme access with PROMETHEUS OS license.
Dark theme active
Experience all 23 themes in PROMETHEUS OS

Training Deep Networks in Practice

Intermediate 19 min· 4 exercises

Make deep networks train reliably: initialize weights correctly, control vanishing and exploding gradients with normalization and residual connections, regularize with dropout and weight decay, tune AdamW with learning-rate schedules, use mixed precision safely, and debug training runs systematically.

By the end of this lesson you will be able to
  • Derive Xavier and He initialization from the goal of preserving activation variance across layers
  • Explain why gradients vanish or explode in deep networks and how normalization and residual connections fix it
  • Compute batch normalization and layer normalization by hand and choose between them
  • Configure AdamW, warmup with cosine decay, gradient clipping and early stopping, and explain each one's role
  • Use FP16 and BF16 mixed precision correctly, including when loss scaling is required
  • Debug a failing training run using the overfit-one-batch test, initial-loss checks and loss-curve diagnosis

Before this: Neural Networks and Backpropagation, Optimization for Machine Learning, Model Evaluation and Metrics

Knowing backpropagation is not the same as being able to train a deep network. Stack twenty layers naively and the loss may sit flat, explode to NaN, or drop nicely and then the model fails on validation data. The techniques in this lesson, mostly developed between 2010 and 2017, are what turned "deep learning" from a fragile research craft into a reliable engineering discipline, and every one of them is still in daily use in training everything from small classifiers to large language models. The final section gives a training-loop template you can reuse.

Intuition: a signal passing through many hands

Think of a deep network as a long chain of people passing a message. If each person whispers a little more quietly than they heard, by the twentieth person the message is inaudible; if each one shouts a little louder, it becomes a deafening roar. The same happens to activations on the forward pass and to gradients on the backward pass, because each layer multiplies the signal by its weights and activation derivatives. Training deep networks is largely about keeping that signal at a sensible volume all the way through: good initialization sets the starting volume, normalization re-levels it at every layer, residual connections add a direct line that bypasses the chain, and gradient clipping caps the occasional shout.

Weight initialization

Why scale matters

Consider a linear layer z=∑i=1ninwixiz = \sum_{i=1}^{n_{\text{in}}} w_i x_i with independent, zero-mean weights and inputs. Then

Var⁡(z)=ninVar⁡(w)Var⁡(x).\operatorname{Var}(z) = n_{\text{in}} \operatorname{Var}(w)\operatorname{Var}(x).

If ninVar⁡(w)>1n_{\text{in}}\operatorname{Var}(w) > 1, variance grows layer after layer (exploding); if it is <1< 1, it shrinks (vanishing). Through LL layers the factor compounds as (ninVar⁡(w))L(n_{\text{in}}\operatorname{Var}(w))^L. With nin=512n_{\text{in}} = 512 and weights drawn with standard deviation 1, the factor per layer is 512, so activation variance passes FP16's maximum within two layers and FP32's within about fifteen.

Xavier (Glorot) and He initialization

  • Xavier/Glorot (Glorot and Bengio, 2010) balances the forward condition ninVar⁡(w)=1n_{\text{in}}\operatorname{Var}(w) = 1 and the backward condition noutVar⁡(w)=1n_{\text{out}}\operatorname{Var}(w) = 1 by compromise: Var⁡(w)=2nin+nout\operatorname{Var}(w) = \frac{2}{n_{\text{in}} + n_{\text{out}}}. As a uniform distribution, w∼U[−a,a]w \sim U[-a, a] with a=6/(nin+nout)a = \sqrt{6/(n_{\text{in}} + n_{\text{out}})}. Derived for symmetric, roughly linear-near-zero activations such as tanh.
  • He/Kaiming (He et al., 2015) accounts for ReLU zeroing half its inputs, which halves the second moment of the activations: Var⁡(w)=2nin\operatorname{Var}(w) = \frac{2}{n_{\text{in}}}. Use it for ReLU-family networks.

Worked numbers. A ReLU layer with nin=512n_{\text{in}} = 512: He standard deviation =2/512=0.0625= \sqrt{2/512} = 0.0625. A tanh layer mapping 512 to 256 units: Xavier uniform bound a=6/768≈0.0884a = \sqrt{6/768} \approx 0.0884.

Biases are usually initialized to zero. PyTorch's nn.Linear uses a Kaiming-uniform-style default scaled for general use; torch.nn.init.kaiming_normal_ and xavier_uniform_ let you choose explicitly. Transformers commonly use small normal initializations (standard deviation around 0.02) with scaled-down initialization for residual output projections, alongside normalization layers.

Vanishing and exploding gradients

By the chain rule, the gradient at layer 1 of an LL-layer network contains a product of LL Jacobians, each including the weight matrix and the activation derivative. If their typical magnitude is below 1, the product vanishes exponentially; above 1, it explodes. The sigmoid's derivative is at most 0.25, so ten sigmoid layers can scale gradients by as little as 0.2510≈9.5×10−70.25^{10} \approx 9.5 \times 10^{-7} even before weights are considered. Early layers then barely learn, which is why sigmoid hidden layers gave way to ReLU-family activations.

Symptoms. Vanishing: loss plateaus early, gradient norms of early layers are orders of magnitude smaller than late ones. Exploding: loss spikes or becomes NaN/inf, gradient norms jump. Recurrent networks, which apply the same weights at every time step, are especially prone (see the sequences lesson).

Remedies, in order of importance: ReLU/GELU activations, proper initialization, normalization layers, residual connections, gradient clipping, and a sensible learning rate.

Normalization layers

Batch normalization

Batch normalization (Ioffe and Szegedy, 2015) normalizes each feature using statistics of the current mini-batch B={x1,…,xm}\mathcal{B} = \{x_1,\dots,x_m\}:

μB=1m∑ixi,σB2=1m∑i(xi−μB)2,x^i=xi−μBσB2+ϵ,yi=γx^i+β,\mu_\mathcal{B} = \frac1m\sum_i x_i, \quad \sigma^2_\mathcal{B} = \frac1m\sum_i (x_i - \mu_\mathcal{B})^2, \quad \hat x_i = \frac{x_i - \mu_\mathcal{B}}{\sqrt{\sigma^2_\mathcal{B} + \epsilon}}, \quad y_i = \gamma \hat x_i + \beta,

with learnable scale γ\gamma and shift β\beta (so the network can undo the normalization if useful) and a small ϵ\epsilon for numerical stability. During training it also keeps running averages of μ\mu and σ2\sigma^2, which are used at inference instead of batch statistics.

Worked example. One feature across a batch of four: (1,2,3,6)(1, 2, 3, 6). μ=3\mu = 3. σ2=(4+1+0+9)/4=3.5\sigma^2 = (4 + 1 + 0 + 9)/4 = 3.5, σ≈1.871\sigma \approx 1.871. Normalized (ignoring ϵ\epsilon): (−1.069,−0.535,0,1.604)(-1.069, -0.535, 0, 1.604). With γ=2\gamma = 2, β=0.5\beta = 0.5: (−1.638,−0.569,0.5,3.707)(-1.638, -0.569, 0.5, 3.707).

Batch norm allows higher learning rates and made very deep CNNs practical. The original paper motivated it by reducing "internal covariate shift"; later work argues its main benefit is smoothing the optimization landscape, and the precise mechanism is still debated. Its weaknesses come from depending on the batch: it behaves poorly with small batches, differs between training and inference modes (forgetting model.eval() is a classic bug), and is awkward for variable-length sequences.

Layer normalization

Layer normalization (Ba, Kiros and Hinton, 2016) computes μ\mu and σ2\sigma^2 over the features of a single example instead of over the batch, then applies the same γ,β\gamma,\beta affine step. It behaves identically in training and inference and is independent of batch size, which is why it is standard in Transformers and RNNs. RMSNorm, a simplification that skips mean-centering and divides by the root-mean-square, is common in recent large language models. Rule of thumb: batch norm for CNNs with reasonable batch sizes, layer norm (or RMSNorm) for Transformers and sequence models.

Residual connections

A residual block (He et al., 2015, ResNet) computes y=x+F(x)y = x + F(x), where FF is a small stack of layers. The layers learn a correction to the identity rather than a whole new mapping. Two effects follow. First, if extra layers are not useful they can learn F≈0F \approx 0, so adding depth should not hurt. Second, the backward pass gets a direct path: ∂y∂x=I+∂F∂x\frac{\partial y}{\partial x} = I + \frac{\partial F}{\partial x}, so gradients can flow through the identity term even if ∂F/∂x\partial F/\partial x is small. Residual connections enabled networks with over 100 layers and are in every Transformer block. Modern Transformers typically use pre-norm blocks, y=x+F(Norm(x))y = x + F(\text{Norm}(x)), which tend to train more stably than placing the normalization after the addition.

Regularization: dropout and weight decay

Dropout (Srivastava et al., 2014) randomly zeros each activation with probability pp during training. Implementations use inverted dropout, scaling surviving activations by 11−p\frac{1}{1-p} so the expected value is unchanged and nothing needs rescaling at test time. With p=0.5p = 0.5, kept activations are doubled. Dropout discourages co-adapted features and acts like an ensemble of thinned networks. It is switched off by model.eval(). Typical rates: 0.1 to 0.5 in MLPs; around 0.1 or none in large Transformers trained on huge datasets, where overfitting is less of a concern.

Weight decay shrinks weights toward zero each step, discouraging large weights, discussed with AdamW below. Other regularizers include data augmentation (often the most effective for vision and audio), label smoothing and early stopping.

Optimizers in practice: AdamW

Adam keeps exponential moving averages of the gradient gtg_t and its square, with bias correction for their zero initialization:

mt=β1mt−1+(1−β1)gt,vt=β2vt−1+(1−β2)gt2,m_t = \beta_1 m_{t-1} + (1-\beta_1) g_t, \qquad v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2,

m^t=mt1−β1t,v^t=vt1−β2t,θt=θt−1−η(m^tv^t+ϵ+λ θt−1).\hat m_t = \frac{m_t}{1-\beta_1^t}, \qquad \hat v_t = \frac{v_t}{1-\beta_2^t}, \qquad \theta_t = \theta_{t-1} - \eta\left(\frac{\hat m_t}{\sqrt{\hat v_t} + \epsilon} + \lambda\,\theta_{t-1}\right).

Here η\eta is the learning rate, β1,β2\beta_1, \beta_2 are decay rates (defaults 0.9 and 0.999), ϵ\epsilon a small constant, and λ\lambda the weight-decay coefficient. The λθ\lambda\theta term written separately like this is the decoupled weight decay of AdamW (Loshchilov and Hutter). In plain Adam, weight decay is implemented by adding λθ\lambda\theta to the gradient, which then gets divided by v^\sqrt{\hat v}, so parameters with large gradient history are regularized less than intended. AdamW applies decay directly to the weights, which generalizes better and makes λ\lambda easier to tune; it is the default optimizer for Transformers.

Worked step. On step 1, m^1=g1\hat m_1 = g_1 and v^1=g12\hat v_1 = g_1^2, so the Adam part of the update is η g1/(∣g1∣+ϵ)≈η sign(g1)\eta\, g_1/(|g_1| + \epsilon) \approx \eta\,\text{sign}(g_1). With η=10−3\eta = 10^{-3}, a parameter whose gradient is 0.02 and one whose gradient is 50 both move by about 10−310^{-3}. Adam's steps are roughly scale-free per parameter, which is why one learning rate works across layers with very different gradient magnitudes.

Practical defaults: AdamW with η\eta around 10−310^{-3} for small models and 10−410^{-4} to 3×10−43\times10^{-4} for Transformers, λ\lambda around 0.01 to 0.1, and no weight decay on biases and normalization parameters. SGD with momentum (0.9) and a tuned schedule remains competitive for CNNs and sometimes generalizes slightly better.

Learning-rate schedules

The learning rate is the single most important hyperparameter. Schedules change it during training:

  • Warmup: increase linearly from near zero over the first few hundred to few thousand steps. Early on, Adam's second-moment estimates are noisy and the weights are random, so large steps can destabilize training; warmup is essential for Transformers.
  • Cosine decay: after warmup, follow ηt=ηmin⁡+12(ηmax⁡−ηmin⁡)(1+cos⁡(π t−TwT−Tw)),\eta_t = \eta_{\min} + \tfrac12(\eta_{\max} - \eta_{\min})\left(1 + \cos\left(\pi\,\frac{t - T_w}{T - T_w}\right)\right), where TwT_w is the number of warmup steps and TT the total.
  • Step decay (divide by 10 at fixed epochs), linear decay, one-cycle, and reduce-on-plateau are common alternatives.

Worked example. ηmax⁡=3×10−4\eta_{\max} = 3\times10^{-4}, ηmin⁡=0\eta_{\min} = 0, Tw=1000T_w = 1000, T=10,000T = 10{,}000. At step 500 (warmup): η=3×10−4×500/1000=1.5×10−4\eta = 3\times10^{-4} \times 500/1000 = 1.5\times10^{-4}. At step 3,250: progress =2250/9000=0.25= 2250/9000 = 0.25, so η=0.5×3×10−4×(1+cos⁡(π/4))≈0.5×3×10−4×1.707≈2.56×10−4\eta = 0.5 \times 3\times10^{-4} \times (1 + \cos(\pi/4)) \approx 0.5 \times 3\times10^{-4} \times 1.707 \approx 2.56\times10^{-4}. At step 5,500: progress 0.5, η=1.5×10−4\eta = 1.5\times10^{-4}. At step 10,000: η=0\eta = 0.

To find a starting ηmax⁡\eta_{\max}, run a short learning-rate range test: increase η\eta exponentially over a few hundred steps and pick a value somewhat below where the loss starts to rise sharply.

Early stopping

Track validation loss (or the metric you care about) every epoch, save a checkpoint whenever it improves, and stop when it has not improved for a set number of epochs (the patience, e.g. 5 to 10). Then restore the best checkpoint. Early stopping is a cheap, effective regularizer that limits how far the weights move from initialization. Use a validation set separate from the final test set.

Mixed precision: FP16 and BF16

Training in 16-bit floating point halves memory for activations and uses the much faster low-precision units on modern accelerators.

FormatSign / exponent / mantissa bitsLargest valueNotes
FP321 / 8 / 23about 3.4×10383.4\times10^{38}reference precision
FP161 / 5 / 1065,504small range: small gradients underflow to zero
BF161 / 8 / 7about 3.4×10383.4\times10^{38}FP32's range, less precision

Mixed precision (Micikevicius et al., 2017) runs most matrix multiplications in 16-bit while keeping an FP32 master copy of the weights and doing sensitive operations (reductions, softmax, loss) in FP32. In PyTorch, torch.autocast chooses the precision per operation automatically.

With FP16, gradients smaller than about 6×10−86\times10^{-8} (the smallest subnormal) flush to zero, so you need loss scaling: multiply the loss by a large factor SS before backward(), so gradients are scaled into representable range, then divide by SS before the optimizer step. A dynamic scaler (GradScaler) raises SS while things are fine and lowers it, skipping the step, when it detects inf/NaN. BF16 has FP32's exponent range, so loss scaling is normally unnecessary; it is the default choice on hardware that supports it (NVIDIA GPUs from the Ampere generation onward, Google TPUs and others). Precision loss in BF16 (8 significant bits, about 2 to 3 significant decimal digits) is usually harmless for training because the master weights and accumulations stay in FP32.

Gradient clipping

Clip by global norm: if ∥g∥2>c\lVert g\rVert_2 > c, rescale g←g⋅c∥g∥2g \leftarrow g \cdot \frac{c}{\lVert g\rVert_2}. This preserves the gradient's direction and caps its size, preventing a single bad batch from destroying training. Example: g=(3,4,12)g = (3, 4, 12) has norm 13; with c=1c = 1 it becomes (0.231,0.308,0.923)(0.231, 0.308, 0.923). A max norm of 1.0 is a common default for Transformers. With FP16 loss scaling, unscale gradients before clipping, or the threshold is applied to scaled values. Logging the pre-clip gradient norm is one of the best health signals you have: steady growth or frequent spikes indicate instability.

Debugging training

A systematic order of checks, following the spirit of Karpathy's widely cited "recipe":

  1. Check the initial loss. With KK balanced classes and a properly initialized softmax classifier, the first loss should be about ln⁡K\ln K (2.303 for 10 classes). Much larger means the output layer is badly scaled or labels are wrong.
  2. Overfit a single batch. Take one batch of, say, 32 examples and train on it repeatedly. A correct model and pipeline drives loss to near zero within a few hundred steps. If it cannot, there is a bug (wrong labels, detached graph, missing zero_grad(), frozen parameters, a learning rate far off), and no amount of data will fix it.
  3. Visualize inputs right before they enter the model, after augmentation and preprocessing. Many bugs are in the data.
  4. Read the loss curves (training and validation on the same plot):
    • Training loss flat from the start: learning rate too low, vanishing gradients, or a bug.
    • Training loss explodes or goes NaN: learning rate too high, no clipping, FP16 overflow, or a log of zero.
    • Training loss falls, validation loss rises after a point: overfitting; add regularization, data or augmentation, or stop earlier.
    • Both high and close together: underfitting; increase capacity or train longer.
    • Noisy, spiky training loss: batch too small or learning rate too high.
  5. Change one thing at a time and keep a log of experiments (see the MLOps lessons).

A PyTorch training-loop template

import math, torch
from torch import nn

def make_optimizer(model, lr=3e-4, wd=0.05):
    decay, no_decay = [], []
    for name, p in model.named_parameters():
        if p.requires_grad:
            (no_decay if p.ndim < 2 else decay).append(p)   # no decay on biases/norms
    return torch.optim.AdamW([{"params": decay, "weight_decay": wd},
                              {"params": no_decay, "weight_decay": 0.0}], lr=lr)

def warmup_cosine(opt, warmup, total):
    def f(step):
        if step < warmup:
            return (step + 1) / warmup
        progress = (step - warmup) / max(1, total - warmup)
        return 0.5 * (1 + math.cos(math.pi * min(1.0, progress)))
    return torch.optim.lr_scheduler.LambdaLR(opt, f)
def train(model, train_loader, val_loader, loss_fn, epochs=20, patience=5,
          device="cuda", amp_dtype=torch.bfloat16, max_norm=1.0):
    model.to(device)
    opt = make_optimizer(model)
    sched = warmup_cosine(opt, warmup=500, total=epochs * len(train_loader))
    use_scaler = amp_dtype == torch.float16          # loss scaling only for FP16
    scaler = torch.amp.GradScaler(device, enabled=use_scaler)  # older: torch.cuda.amp
    best, bad_epochs = float("inf"), 0
    for epoch in range(epochs):
        model.train()
        for xb, yb in train_loader:
            xb, yb = xb.to(device), yb.to(device)
            with torch.autocast(device_type=device, dtype=amp_dtype):
                loss = loss_fn(model(xb), yb)
            opt.zero_grad(set_to_none=True)
            scaler.scale(loss).backward()
            scaler.unscale_(opt)                          # clip true gradients
            nn.utils.clip_grad_norm_(model.parameters(), max_norm)
            scaler.step(opt); scaler.update(); sched.step()
        model.eval(); total, n = 0.0, 0
        with torch.no_grad(), torch.autocast(device_type=device, dtype=amp_dtype):
            for xb, yb in val_loader:
                xb, yb = xb.to(device), yb.to(device)
                total += loss_fn(model(xb), yb).item() * len(xb); n += len(xb)
        val = total / n
        print(f"epoch {epoch} val_loss {val:.4f} lr {sched.get_last_lr()[0]:.2e}")
        if val < best:
            best, bad_epochs = val, 0
            torch.save(model.state_dict(), "best.pt")     # checkpoint the best model
        elif (bad_epochs := bad_epochs + 1) >= patience:
            break                                          # early stopping
    model.load_state_dict(torch.load("best.pt"))
    return model

What each piece does: parameter groups exclude biases and normalization weights from decay; LambdaLR multiplies the base learning rate by the warmup-cosine factor every step; autocast runs the forward pass in BF16 or FP16; the scaler is a pass-through when disabled (BF16) and handles loss scaling for FP16; gradients are unscaled before clipping; model.eval() and torch.no_grad() switch off dropout, use batch-norm running statistics and skip graph building during validation; the best checkpoint is saved and restored. On a CPU, set device="cpu" (CPU autocast supports BF16). The GradScaler constructor taking a device argument is the current API; older PyTorch versions use torch.cuda.amp.GradScaler().

Common pitfalls

  • Forgetting model.train() / model.eval(), so dropout stays on at evaluation or batch norm uses batch statistics at inference.
  • No warmup for Transformers, or a peak learning rate copied from a different model size or batch size.
  • Using Adam with L2 regularization when you meant AdamW decoupled decay; and decaying biases and norm parameters.
  • FP16 without loss scaling, or clipping before unscaling.
  • Batch norm with tiny batches (for example 2 per GPU): statistics are too noisy; use group norm or layer norm, or synchronized batch norm.
  • Judging by training loss alone; always plot validation loss on the same axes.
  • Tuning many things at once without logging, making it impossible to know what helped.

In practice

  • Default recipe for a new model: He or framework-default initialization, normalization layers, residual connections if deeper than a few layers, AdamW with warmup plus cosine decay, gradient clipping at 1.0, BF16 autocast where supported, early stopping on validation loss.
  • Always run the overfit-one-batch test before a long run; it takes a minute and catches most bugs.
  • Log training loss, validation loss, learning rate and gradient norm every few steps to an experiment tracker.
  • When scaling batch size up, scale the learning rate up too (roughly linearly for SGD, more gently for Adam) and lengthen warmup, then re-verify.
  • Fine-tuning pretrained models usually needs a learning rate 10 to 100 times smaller than training from scratch, short warmup, and few epochs.

Key takeaways

  • Xavier and He initialization keep activation and gradient variance roughly constant across layers, with He's factor of 2 compensating for ReLU zeroing half its inputs.
  • Vanishing and exploding gradients come from multiplying many layer Jacobians; ReLU-family activations, normalization and residual connections are the main cures.
  • Batch norm normalizes over the batch and suits CNNs, while layer norm normalizes within each example and is standard in Transformers.
  • AdamW decouples weight decay from the adaptive gradient step and, with warmup plus cosine decay and gradient clipping, is the default recipe for modern networks.
  • BF16 mixed precision has FP32's range and usually needs no loss scaling, whereas FP16 needs dynamic loss scaling to avoid gradient underflow.
  • Before any long run, check the initial loss and confirm the model can overfit a single batch; then diagnose with training and validation loss curves.

Terms in this lesson

He initialization
Drawing weights with variance 2 / fan-in so that ReLU networks preserve activation variance across layers.
Vanishing gradient
Gradients shrinking exponentially as they propagate backwards through many layers, so early layers barely learn.
Batch normalization
Normalizing each feature with the mean and variance of the current mini-batch, followed by a learnable scale and shift.
Layer normalization
Normalizing across the features of each individual example, independent of batch size and identical in training and inference.
Residual connection
A skip path that adds a block's input to its output, y = x + F(x), giving gradients a direct route backwards.
Inverted dropout
Zeroing activations with probability p during training and scaling survivors by 1/(1-p) so no rescaling is needed at inference.
AdamW
The Adam optimizer with weight decay applied directly to the weights rather than added to the gradient.
Warmup
Gradually increasing the learning rate from near zero over the first training steps to avoid early instability.
Loss scaling
Multiplying the loss by a large factor before backpropagation in FP16 training so small gradients do not underflow, then dividing it out before the update.
Gradient clipping
Rescaling the gradient when its global norm exceeds a threshold, preserving direction while capping step size.

Check your understanding

  1. 1.

    Using He initialization, what standard deviation should the weights of a ReLU layer with 128 inputs have?

  2. 2.

    You are training a Transformer with a per-GPU batch of 2 sequences of varying length. Which normalization is the best fit?

  3. 3.

    Why does AdamW decouple weight decay from the gradient update?

  4. 4.

    A warmup-cosine schedule has peak learning rate 1e-3, minimum 0, 1,000 warmup steps and 11,000 total steps. What is the learning rate at step 6,000?

  5. 5.

    Your new model cannot drive the loss on a single batch of 16 examples below its starting value after 500 steps. What should you conclude?

Exercises

Work each problem before opening the solution.

  1. Exercise 1Core

    Apply batch normalization to the batch (4,8,6,2)(4, 8, 6, 2) for a single feature with γ=0.5\gamma = 0.5, β=1\beta = 1 (ignore ϵ\epsilon). Then apply layer normalization to the single example (4,8,6,2)(4, 8, 6, 2) treated as four features with γ=1\gamma = 1, β=0\beta = 0. Why do the numbers coincide, and when would they differ?

    Show solution

    Mean =20/4=5= 20/4 = 5. Deviations (−1,3,1,−3)(-1, 3, 1, -3), variance =(1+9+1+9)/4=5= (1 + 9 + 1 + 9)/4 = 5, standard deviation ≈2.236\approx 2.236.

    Normalized: (−0.447,1.342,0.447,−1.342)(-0.447, 1.342, 0.447, -1.342).

    Batch norm with γ=0.5\gamma = 0.5, β=1\beta = 1: (0.776,1.671,1.224,0.329)(0.776, 1.671, 1.224, 0.329).

    Layer norm with γ=1\gamma = 1, β=0\beta = 0: (−0.447,1.342,0.447,−1.342)(-0.447, 1.342, 0.447, -1.342).

    The normalized values coincide only because the same four numbers were used once as a batch of one feature and once as one example with four features. In a real network the inputs are a matrix (batch by features): batch norm normalizes each column (a feature across examples), layer norm each row (an example across features). Batch norm's result for one example depends on the other examples in the batch; layer norm's does not.

  2. Exercise 2Core

    Run two steps of AdamW by hand for a single parameter with θ0=1.0\theta_0 = 1.0, gradients g1=0.5g_1 = 0.5 and g2=−0.1g_2 = -0.1, η=0.01\eta = 0.01, β1=0.9\beta_1 = 0.9, β2=0.999\beta_2 = 0.999, ϵ=0\epsilon = 0 and weight decay λ=0.1\lambda = 0.1. Show mm, vv, the bias-corrected values and θ\theta after each step.

    Show solution

    Step 1. m1=0.1×0.5=0.05m_1 = 0.1 \times 0.5 = 0.05. v1=0.001×0.25=0.00025v_1 = 0.001 \times 0.25 = 0.00025. m^1=0.05/0.1=0.5\hat m_1 = 0.05/0.1 = 0.5. v^1=0.00025/0.001=0.25\hat v_1 = 0.00025/0.001 = 0.25, v^1=0.5\sqrt{\hat v_1} = 0.5. Adam term =0.5/0.5=1= 0.5/0.5 = 1. Decay term =λθ0=0.1= \lambda\theta_0 = 0.1. θ1=1.0−0.01(1+0.1)=0.989\theta_1 = 1.0 - 0.01(1 + 0.1) = 0.989.

    Step 2. m2=0.9×0.05+0.1×(−0.1)=0.045−0.01=0.035m_2 = 0.9 \times 0.05 + 0.1 \times(-0.1) = 0.045 - 0.01 = 0.035. v2=0.999×0.00025+0.001×0.01=0.00024975+0.00001=0.00025975v_2 = 0.999 \times 0.00025 + 0.001 \times 0.01 = 0.00024975 + 0.00001 = 0.00025975. Bias corrections: 1−0.92=0.191 - 0.9^2 = 0.19, 1−0.9992=0.0019991 - 0.999^2 = 0.001999. m^2=0.035/0.19≈0.18421\hat m_2 = 0.035/0.19 \approx 0.18421. v^2=0.00025975/0.001999≈0.12994\hat v_2 = 0.00025975/0.001999 \approx 0.12994, v^2≈0.36047\sqrt{\hat v_2} \approx 0.36047. Adam term ≈0.18421/0.36047≈0.5110\approx 0.18421/0.36047 \approx 0.5110. Decay term =0.1×0.989=0.0989= 0.1 \times 0.989 = 0.0989. θ2=0.989−0.01(0.5110+0.0989)≈0.989−0.0061=0.9829\theta_2 = 0.989 - 0.01(0.5110 + 0.0989) \approx 0.989 - 0.0061 = 0.9829.

    Notice that the gradient changed sign at step 2, but momentum kept the update positive (the parameter still decreased), and the first step moved by exactly η\eta in the Adam part, as the lesson predicted.

  3. Exercise 3Core

    Explain why FP16 training needs loss scaling while BF16 usually does not. A gradient of 2×10−82\times10^{-8} arises in a layer: what happens to it in FP16 without scaling, and with a loss scale of 216=65,5362^{16} = 65{,}536?

    Show solution

    FP16 has 5 exponent bits, giving a range from about 6×10−86\times10^{-8} (smallest subnormal; smallest normal is about 6.1×10−56.1\times10^{-5}) up to 65,504. BF16 has 8 exponent bits, the same as FP32, so it represents values down to about 10−3810^{-38} (normal range) and gradients practically never underflow.

    Without scaling, 2×10−82\times10^{-8} is below FP16's smallest subnormal and rounds to zero: that parameter receives no update, and if many gradients are this small, learning stalls silently.

    With scale 65,53665{,}536: 2×10−8×65,536≈1.31×10−32\times10^{-8}\times65{,}536 \approx 1.31\times10^{-3}, comfortably representable (though in the normal range it still has only about 3 significant digits). After backward, the scaler divides by 65,536 in FP32 before the optimizer step, recovering approximately 2×10−82\times10^{-8}. If scaling makes some gradient overflow above 65,504 (becoming inf), the dynamic scaler skips that step and halves the scale.

  4. Exercise 4Challenge

    Write a PyTorch experiment that shows the effect of residual connections on gradient flow. Build a 30-layer network of width 64 with tanh activations and PyTorch's default initialization in two versions: (a) plain, x←tanh⁡(Linear(x))x \leftarrow \tanh(\text{Linear}(x)); (b) residual blocks x←x+Linear(tanh⁡(x))x \leftarrow x + \text{Linear}(\tanh(x)). Do one backward pass on random data and print the gradient norm of the first and last linear layers' weights for each. Explain the expected result.

    Show solution
    import torch
    from torch import nn
    
    torch.manual_seed(0)
    D, L = 64, 30
    
    class Plain(nn.Module):
        def __init__(self):
            super().__init__()
            self.layers = nn.ModuleList([nn.Linear(D, D) for _ in range(L)])
        def forward(self, x):
            for lin in self.layers:
                x = torch.tanh(lin(x))
            return x
    
    class Residual(nn.Module):
        def __init__(self):
            super().__init__()
            self.layers = nn.ModuleList([nn.Linear(D, D) for _ in range(L)])
        def forward(self, x):
            for lin in self.layers:
                x = x + lin(torch.tanh(x))
            return x
    
    x = torch.randn(128, D)
    for name, net in [("plain", Plain()), ("residual", Residual())]:
        net(x).pow(2).mean().backward()
        g_first = net.layers[0].weight.grad.norm().item()
        g_last = net.layers[-1].weight.grad.norm().item()
        print(f"{name:9s} first {g_first:.2e}  last {g_last:.2e}  ratio {g_first / g_last:.2e}")
    

    Expected: PyTorch's default nn.Linear initialization draws weights from U[−1/nin,1/nin]U[-1/\sqrt{n_{\text{in}}}, 1/\sqrt{n_{\text{in}}}], a variance of 1/(3nin)1/(3n_{\text{in}}), well below what preserves gradient scale, and tanh's slope is at most 1. In the plain network the backward signal is multiplied by a factor below 1 at every layer, so the first layer's gradient norm is many orders of magnitude smaller than the last layer's: vanishing gradients. (A NumPy re-implementation with the same initialization and seed-dependent data gave a first/last ratio of about 10−710^{-7}.) In the residual version the identity path gives ∂xk+1/∂xk=I+∂F/∂xk\partial x_{k+1}/\partial x_k = I + \partial F/\partial x_k, so the gradient reaching early layers always includes the upstream gradient carried unchanged along the skip path, and first- and last-layer gradient norms come out of the same order (ratio near 1 in the same re-implementation). Exact numbers depend on the seed; the many-orders-of-magnitude gap is the point. Adding pre-norm LayerNorm, as Transformers do, additionally keeps the residual stream's scale under control as depth grows.

Further reading

Go further

Sources

Fact-checked and last reviewed 2026-11-05

Stay in the loop
Prometheus OS™ · Native AI OS · Built for the future © 2025 - 2026