Deep Learning · Module 10 of 26 · 9 concepts · about 20 min

Initialization & Gradient Problems

Keeping signals and gradients usable from the first update onward.

At work: Getting deep models to train at all, especially the very deep ones behind modern vision and language products.

Xavier/He initialization, vanishing and exploding gradients

What it is

Every layer scales both forward activations and backward gradients. When many scales below one multiply together, early layers receive vanishing gradients; when scales above one compound, values or updates explode. Xavier initialization chooses variance based on fan-in and fan-out for roughly variance-preserving tanh-like layers, while He initialization is calibrated for ReLU-like layers that discard half their signal. Gradient clipping is a safety mechanism for occasional extreme updates, not a substitute for a consistently ill-scaled model.

Why it matters

Bad initialization can make a correct model appear broken before it has learned anything. These are signal-scale problems, not evidence that the dataset is impossible. Watching activation and gradient distributions by layer can distinguish a structural scaling failure from ordinary underfitting, and is especially valuable in deep recurrent or Transformer training.

In the real world: The right starting weights are the difference between a deep model that trains and one that never does.

Visual example

Gradient health

Useful gradients stay in a workable range across layers.

vanishinghealthy
12345600.20.40.60.81layer depthgradient magnitude
import torch.nn as nn

layer = nn.Linear(256, 256)
nn.init.kaiming_normal_(layer.weight, nonlinearity="relu")  # ReLU stacks
nn.init.zeros_(layer.bias)

tanh_layer = nn.Linear(256, 256)
nn.init.xavier_uniform_(tanh_layer.weight)                   # tanh/sigmoid

Initializing every weight to zero. Symmetric neurons get identical gradients and remain identical, so a whole layer learns only one feature.

Practise it: ReLU, Forward and Backward

Why initialization matters at all: breaking symmetry

What it is

Weights must start at *different* random values, not merely small ones. If every weight in a layer holds the same number, every neuron in that layer computes the same pre-activation, receives the same gradient from backpropagation, and is updated by the same amount — so it stays identical to its neighbours forever. A 512-unit layer initialized to a constant has the representational capacity of exactly one unit, no matter how long it trains. Random initialization is what makes the units distinguishable in the first place; biases can safely start at zero because the weights already break the tie.

Why it matters

This is the one initialization failure that produces no error, no NaN, and no warning — just a model whose loss falls to the level of a much smaller model and then stops. Knowing the mechanism turns a mystifying plateau into a one-line check: if the rows of a weight matrix are identical (or become identical), the layer is collapsed.

In the real world: If all weights start equal, every unit learns the same thing and the model is no better than a tiny one.

Visual example

Deep learning

Identical weights make identical neurons, and backpropagation keeps them identical.

Constant initialization collapses a layer

  1. W initialized to one constantevery row of W is the same vector
  2. forward passevery neuron emits the same pre-activation
  3. loss.backward()every row receives the same gradient
  4. optimizer.step()every row moves the same amount — still identical

A 512-unit layer with constant weights has the capacity of one unit, permanently.

import torch
import torch.nn as nn

layer = nn.Linear(4, 3)
nn.init.constant_(layer.weight, 0.1)      # every neuron identical
out = layer(torch.randn(8, 4))
out.sum().backward()
layer.weight.grad                          # all three rows are the same tensor

# the check worth keeping: how many distinct neurons does this layer have?
torch.linalg.matrix_rank(layer.weight)     # tensor(1)

Believing 'zero is a neutral starting point'. Zero is the most symmetric value there is, which makes it the worst one: it guarantees the collapse rather than merely risking it.

Vanishing gradients, precisely

What it is

Backpropagation (module 7) builds each early-layer gradient as a product of one factor per layer between it and the loss. A network 30 layers deep multiplies roughly 30 such factors together. If each factor averages 0.8, the product is 0.8³⁰ ≈ 0.001; at 0.5 it is under 10⁻⁹. Nothing is broken — the chain rule is simply doing arithmetic — but the first layers receive a gradient so small that any reasonable learning rate leaves them effectively frozen at their random initialization while the last few layers train normally.

Why it matters

It explains the signature symptom of an untrainable deep network: the loss falls a little, then flattens well above where a shallower model lands. The fix is never 'train longer' — a gradient of 10⁻⁹ does not accumulate into progress. It is to change the factors: a better initialization scale, a non-saturating activation, normalization, or a residual path that skips the product entirely.

In the real world: Why early layers of a deep model stop learning, capping accuracy on tasks like image search.

Visual example

Chain rule product

Each layer contributes one factor to the product; 0.8 per layer is harmless at depth 3 and fatal at depth 30.

running product of per-layer factors

input
0.8
0.8
0.8
0.8
0.8
0.8
0.8
0.8
output
0.8
0.64
0.51
0.41
0.33
0.26
0.21
0.17

Why this matters

Vanishing gradients are not a mysterious pathology. They are what repeated multiplication by a number below one does, and the depth of the network sets how many times it happens.

Extend the same 0.8 to 30 layers and the product is 0.0012; at 50 layers it is 0.000014.

import torch.nn as nn

deep = nn.Sequential(*[nn.Sequential(nn.Linear(64, 64), nn.Tanh()) for _ in range(30)])
loss = deep(x).square().mean()
loss.backward()

# print gradient norm per layer: the diagnostic that names the problem
for name, param in deep.named_parameters():
    if name.endswith("weight"):
        print(name, float(param.grad.norm()))
# layer 29: 0.41 ... layer 0: 0.0000002 -> the front of the network is frozen

Reading only the total gradient norm. It is dominated by the healthy final layers, so it looks perfectly normal while the first twenty layers get nothing. Gradient norms are only diagnostic *per layer*.

Practise it: ReLU, Forward and Backward

Exploding gradients, the mirror case

What it is

The same product runs the other way when the per-layer factors exceed one: 1.5³⁰ ≈ 190,000. The symptoms are louder than vanishing — a loss that spikes from 2.1 to 40,000 in one step, then becomes nan and stays there, because one enormous update threw every parameter far outside the region where the forward pass is finite. Recurrent networks are the classic victims, since the same weight matrix is applied once per timestep, but any deep stack with a slightly-too-large initialization or learning rate can do it.

Why it matters

A nan loss is unrecoverable: once parameters are nan, every subsequent gradient is nan, and the run is dead however many epochs remain. Recognising the spike *before* the nan — and knowing it is a scale problem rather than a data problem — is the difference between a two-minute fix and a day spent auditing the dataset.

In the real world: Why training suddenly blows up, often seen in sequence models on sensor or transaction data.

Visual example

Interactive instability

Raise the learning rate past the stable range and watch the same loss stop converging and start running away.

Learning rate η

η = 0.20

steady convergence

Minimise L(w) = (w − 3)². The optimum is w = 3.

Loss after each update

w = -3
lossupdate

gradient = 2(w − 3) = -12 → next w = -0.60

Why this matters

Exploding updates are not rare or exotic. They are what any step longer than the curvature allows does, and a deep network multiplies that effect once per layer.

  1. 1StableAt η = 0.2 the loss falls smoothly to zero.
  2. 2OscillatingAt η = 0.8 it overshoots the optimum and comes back each step.
  3. 3ExplodingAt η ≥ 1.05 each step overshoots further than the last — the loss diverges.
import torch

loss.backward()
total = torch.nn.utils.clip_grad_norm_(model.parameters(), float("inf"))  # measure only
if not torch.isfinite(total) or total > 1e3:
    print(f"step {step}: gradient norm {float(total):.1f} — about to explode")

# the definitive check, cheap enough to leave on
assert torch.isfinite(loss), f"loss became {loss.item()} at step {step}"

Restarting from the last checkpoint after a nan without changing anything. The same batch and the same scale reproduce the same explosion; the run needs a smaller learning rate, clipping, or a corrected initialization, not another attempt.

Practise it: ReLU, Forward and Backward

Xavier/Glorot initialization

What it is

Xavier initialization draws weights with variance 2 / (fan_in + fan_out), which is the scale that keeps a layer's output variance roughly equal to its input variance for activations that are near-linear around zero — tanh and sigmoid. The derivation is short: a unit summing fan_in independent inputs multiplies the input variance by fan_in × Var(w), so setting Var(w) ≈ 1/fan_in makes that factor one. Averaging fan-in and fan-out balances the forward pass against the backward pass, which sees the transposed matrix and therefore the opposite fan.

Why it matters

It is the concrete answer to 'how random is random enough'. Too small a variance shrinks the activations layer by layer until the signal is numerically negligible; too large saturates tanh, where the derivative is near zero and the gradient dies for a different reason. Xavier picks the one scale where a 20-layer tanh network still has recognisable activations at layer 20.

In the real world: A standard setting that helps networks start training steadily.

Visual example

Activation variance by depth

Xavier holds the activation variance flat across depth; a fixed small scale decays it toward zero and a fixed large scale saturates.

Xavier (2 / (fan_in + fan_out))fixed std 0.01 (too small)fixed std 0.5 (too large)
024681000.511.52layer depthactivation varianceinput variance — the level to preserve

Where 1/fan_in comes from

z = Σ over fan_in of (x·w) -> Var(z) = fan_in × Var(x) × Var(w)

want Var(z) = Var(x) -> Var(w) = 1 / fan_in

the backward pass sees Wᵀ, so it wants 1 / fan_out

Xavier compromises: Var(w) = 2 / (fan_in + fan_out)

Why this matters

It shows that initialization is not a matter of taste. There is one scale that preserves signal through depth, and it is computable from the layer's own dimensions.

The red curve is a network whose deep layers see nothing; the amber one saturates tanh, where the derivative is near zero.

import torch.nn as nn

def tanh_block(width):
    layer = nn.Linear(width, width)
    nn.init.xavier_uniform_(layer.weight, gain=nn.init.calculate_gain("tanh"))
    nn.init.zeros_(layer.bias)
    return nn.Sequential(layer, nn.Tanh())

net = nn.Sequential(*[tanh_block(256) for _ in range(20)])
# activation std stays near 0.6 at every depth instead of decaying to 1e-6

Applying Xavier by hand and forgetting that PyTorch already initialized the layer. nn.init.*_ overwrites in place, so it must run on the module you will actually train — re-creating the layer afterwards silently discards your initialization.

He/Kaiming initialization for ReLU

What it is

ReLU sets every negative pre-activation to exactly zero, which removes about half the signal's variance. Xavier's scale, derived for a near-linear activation, therefore undershoots by a factor of two per layer for a ReLU stack — and a factor of two per layer compounds exactly like every other factor in this module. He initialization corrects it with variance 2 / fan_in, the extra 2 paying for the half that ReLU discards. nn.init.kaiming_normal_(w, nonlinearity="relu") is the direct form; calculate_gain supplies the equivalent multiplier for leaky ReLU and other variants.

Why it matters

This is why the default choice of initializer depends on the activation, not on the architecture. In a 30-layer ReLU network, Xavier's missing factor of two becomes 2⁻³⁰ — the difference between activations of order one at the output and activations of order 10⁻⁹. Matching the initializer to the nonlinearity is a one-line change that decides whether the network trains.

In the real world: The default for image models built with ReLU layers.

Visual example

Initializer choice

The activation that follows a layer decides its initializer, because it decides how much variance survives.

which initializer, and why
activationinitializerweight variance
tanh / sigmoidxavier_uniform_2 / (fan_in + fan_out)
ReLUkaiming_normal_2 / fan_in
Leaky ReLU (a)kaiming_normal_(a=a)2 / ((1 + a²) · fan_in)
linear output headxavier_uniform_2 / (fan_in + fan_out)

Common misconception

Wrong idea: Xavier is the general-purpose initializer and He is a minor refinement.

Correct idea: they are calibrated for different activations. For a deep ReLU stack Xavier is wrong by 2 per layer, and depth turns that into orders of magnitude.

import torch.nn as nn

def relu_block(width):
    layer = nn.Linear(width, width)
    nn.init.kaiming_normal_(layer.weight, mode="fan_in", nonlinearity="relu")
    nn.init.zeros_(layer.bias)
    return nn.Sequential(layer, nn.ReLU())

net = nn.Sequential(*[relu_block(256) for _ in range(30)])
nn.init.calculate_gain("relu")        # 1.414... = sqrt(2), the correction

Passing nonlinearity="relu" to kaiming_* while the layer is actually followed by nn.LeakyReLU(0.2) or GELU. The gain is then wrong by a small factor that, again, compounds with depth.

Practise it: ReLU, Forward and Backward

Gradient clipping, and why RNNs need it most

What it is

Module 6 covers the mechanics — clip_grad_norm_ rescales the gradient by its global norm between backward() and step(). clip_grad_value_ is the blunter alternative: it clamps each element independently, which does change the direction rather than only its length.

Why it matters

Exploding gradients are not evenly distributed across architectures: a recurrent model applies the same weight matrix at every timestep (module 15), so one unstable eigenvalue compounds over the whole sequence length rather than over a fixed handful of layers. Clipping is close to mandatory for RNNs specifically, in a way it usually isn't for a feedforward network with reasonable initialization.

In the real world: Keeps models on long transaction or sensor histories from blowing up during training.

import torch.nn as nn

for batch in loader:
    optimizer.zero_grad()
    loss = loss_fn(model(batch.x), batch.y)
    loss.backward()
    norm = nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    optimizer.step()
    # norm is the value BEFORE clipping — log it to see how often you clip

Treating a permanently-clipping run as fixed. If almost every step hits the threshold, clipping has become a strange learning-rate schedule and the real problem — scale, initialization, or learning rate — is still there. Log the pre-clip norm and check how often it actually binds.

Residual connections as the architectural fix

What it is

A residual block computes y = x + F(x) rather than y = F(x). On the backward pass the addition sends the incoming gradient down two routes: through F, where it picks up the block's factor, and straight through the +, where it passes unchanged with a local derivative of exactly 1. A 50-block residual network therefore always has one path from the loss to layer 0 whose gradient is not multiplied by anything, no matter what the 50 blocks do to their own copy. This is why depth stopped being a barrier: skip connections remove the product, rather than tuning its factors.

Why it matters

It reframes depth. Without residuals, each added block multiplies another factor into every earlier gradient and makes training harder; with them, a block can learn F(x) ≈ 0 and become a near-identity, so adding capacity is close to free. Every modern deep architecture — ResNets, Transformer blocks (module 18), diffusion U-Nets — is built on this one property.

In the real world: The shortcut design that made very deep image and language models, like those behind photo search, trainable.

Visual example

Gradient routing

Adding the input back gives the gradient a second route with a local derivative of exactly 1.

plain block: one route
routegradient factor
through F× ∂F/∂x (may be ≪ 1)
(no other route)—
y = F(x) -> y = x + F(x)
residual block: two routes
routegradient factor
through F× ∂F/∂x (may be ≪ 1)
through the +× 1, unchanged

Why this matters

Vanishing gradients come from a product. A skip connection is valuable because it is an addition, and additions do not compound.

Stack 50 blocks and the plain network multiplies 50 factors into every early gradient; the residual network still has one unmultiplied path.

import torch.nn as nn

class ResidualBlock(nn.Module):
    def __init__(self, width):
        super().__init__()
        self.body = nn.Sequential(
            nn.Linear(width, width), nn.ReLU(), nn.Linear(width, width)
        )

    def forward(self, x):
        return x + self.body(x)      # the + is the gradient highway

net = nn.Sequential(*[ResidualBlock(256) for _ in range(50)])

Writing return self.body(x) after refactoring, or adding a stride/width change inside the block without projecting x to match. The shapes then disagree — or worse, they happen to agree and you have silently deleted the skip connection and the property that made the depth trainable.

Diagnosing 'my 50-layer network won't train'

What it is

The symptoms of this module's failures overlap, so the diagnosis is a short ordered checklist rather than a guess. Loss immediately nan: exploding — lower the learning rate, add clipping, check for a division or log(0) in the loss. Loss flat from step 1 at exactly ln(n_classes): nothing is reaching the early layers — check per-layer gradient norms, the initializer against the activation, and whether the blocks are residual. Loss falls then stalls far above a shallower baseline: depth is hurting, which is the residual-connection case. Some layers' weights never change: dead ReLUs (module 3) or a collapsed symmetric layer. Each branch names a different fix, and applying the wrong one wastes a full training run.

Why it matters

The instinct when a deep model will not train is to change everything at once — optimiser, learning rate, architecture, batch size — which guarantees that whatever happens next teaches you nothing (module 25). Three cheap measurements — per-layer gradient norm, per-layer activation std, and the fraction of parameters that actually moved after one step — distinguish all four cases before you change any hyperparameter.

In the real world: A checklist that saves a team from rewriting a model when the fix is one setting.

Visual example

Deep learning

Four distinct failures, each with its own measurement and its own fix.

Deep-network triage

  1. nan loss in a few stepsexploding — clip, lower η, check the loss for log(0)
  2. flat at ln(n_classes)front of the network is frozen — per-layer grad norms
  3. falls, then stalls highdepth is hurting — add residual connections
  4. some weights never movedead ReLUs or a collapsed symmetric layer

Measure per-layer gradient norm and activation std before changing a single hyperparameter.

import torch

before = {n: p.detach().clone() for n, p in model.named_parameters()}
loss = loss_fn(model(x), y)
loss.backward()
optimizer.step()

for name, param in model.named_parameters():
    grad = 0.0 if param.grad is None else float(param.grad.norm())
    moved = float((param.detach() - before[name]).norm())
    print(f"{name:40s} grad={grad:.2e} moved={moved:.2e}")
# grad ~ 1e-9 at the front  -> vanishing; moved == 0 everywhere -> nothing trains

Concluding 'the dataset is too hard' from a flat loss curve. Run the overfit-one-batch test (module 25) first: a model that cannot drive the loss to near zero on eight fixed examples has a scale, shape, or objective bug, not a data problem.