Backpropagation, section 4 of 4, 3h

Vanishing and exploding gradients, measured

By the end: You can measure gradient flow through a deep network and show how activation, initialisation, normalisation and residuals change it.

  • Gradient norm per layer
  • Sigmoid versus ReLU
  • Initialisation that keeps signals steady
  • What residuals and normalisation fix

New to backpropagation? A freshly built network has random weights, so its answers are nonsense. Backpropagation is how that changes. The network makes a prediction, the prediction is compared with the right answer, and the size of the mistake is pushed backwards through the layers, working out how much each weight contributed to it. Every weight then moves a little in the direction that would have made the mistake smaller, and the whole thing repeats millions of times. Why it matters

Deep networks were hard to train for years for one reason: the signal telling the early layers how to change either shrank to nothing or blew up on the way back. This section measures that directly, layer by layer, instead of describing it. Then it shows what each of the standard fixes does to those numbers — the activation you choose, how the weights are initialised, normalisation, and residual connections — which is why modern architectures look the way they do.

Measure it#

import torch, torch.nn as nn

def deep_mlp(act, depth=20, width=256):
    layers = []
    for _ in range(depth):
        layers += [nn.Linear(width, width), act()]
    return nn.Sequential(*layers, nn.Linear(width, 1))

for act in (nn.Sigmoid, nn.Tanh, nn.ReLU):
    torch.manual_seed(0)
    model = deep_mlp(act)
    model(torch.randn(64, 256)).pow(2).mean().backward()
    norms = [m.weight.grad.norm().item() for m in model if isinstance(m, nn.Linear)]
    print(act.__name__, f"first layer {norms[0]:.3e}", f"last layer {norms[-1]:.3e}")

# Sigmoid  first layer 4.237e-18  last layer 1.324e+00
# Tanh     first layer 3.593e-06  last layer 7.948e-02
# ReLU     first layer 1.330e-09  last layer 1.901e-02
Ran as shown, on a 20-layer network with PyTorch's default initialisation. The sigmoid network's first layer receives a gradient eighteen orders of magnitude below the last: in 32-bit arithmetic that is indistinguishable from nothing at all.

With sigmoid, gradients in the first layers are typically many orders of magnitude smaller than in the last, because each layer multiplies the gradient by a derivative of at most 0.25. The early layers barely learn. ReLU passes gradients through unchanged where active, and the gap shrinks dramatically.

Notice that ReLU shrinks the gap by nine orders of magnitude and still leaves seven. The activation is not the whole story, which is the honest version of an argument usually stated as though it were. What is left is the size of the weights, and that is set by the initialisation.

Initialisation#

If weights are too large, activations and gradients grow at every layer; too small, they shrink. He initialisation, with standard deviation equal to the square root of 2 divided by the number of inputs, keeps signal size roughly steady through ReLU layers. PyTorch's defaults are chosen with the same goal in mind.

Set the initialisation deliberately and run the same measurement. With He initialisation the first layer's gradient is 4.84 and the last is 24.9 — a factor of five apart rather than ten million, on the identical architecture. Then set every weight from a normal distribution with standard deviation 0.01, which looks harmlessly small, and every gradient in the network reads exactly zero: the signal has underflowed to nothing on the way forward, and there is nothing to send back.

def measure(init_fn):
    torch.manual_seed(0)
    model = deep_mlp(nn.ReLU)
    for m in model:
        if isinstance(m, nn.Linear):
            init_fn(m.weight)
            nn.init.zeros_(m.bias)
    model(torch.randn(64, 256)).pow(2).mean().backward()
    norms = [m.weight.grad.norm().item() for m in model if isinstance(m, nn.Linear)]
    return norms[0], norms[-1]

he = lambda w: nn.init.kaiming_normal_(w, nonlinearity="relu")
tiny = lambda w: nn.init.normal_(w, 0.0, 0.01)

print("%.3e %.3e" % measure(he))      # 4.843e+00 2.486e+01
print("%.3e %.3e" % measure(tiny))    # 0.000e+00 0.000e+00
Ran as shown. The second line is not a small number, it is zero: a 20-layer network with a plausible-looking initialisation that cannot train at all, and whose loss curve is a flat line with no error message anywhere.

Residuals and normalisation#

Adding each block's input to its output gives gradients a direct path back through the network, and normalisation keeps each layer's inputs in a steady range. Add both to the experiment above and the first-layer gradient becomes comparable to the last even at depth 100. This is why every modern deep architecture uses them.

class Block(nn.Module):
    def __init__(self, width):
        super().__init__()
        self.norm = nn.LayerNorm(width)
        self.lin = nn.Linear(width, width)
        self.act = nn.ReLU()

    def forward(self, x):
        return x + self.act(self.lin(self.norm(x)))     # the addition is the whole trick

for depth in (20, 100):
    torch.manual_seed(0)
    model = nn.Sequential(*[Block(256) for _ in range(depth)], nn.Linear(256, 1))
    model(torch.randn(64, 256)).pow(2).mean().backward()
    norms = [m.lin.weight.grad.norm().item() for m in model if isinstance(m, Block)]
    print(depth, "%.3e %.3e" % (norms[0], norms[-1]))

# 20   3.456e+00 1.754e+00
# 100  1.388e+02 2.635e+01
Ran as shown. At depth 100 the first block's gradient is larger than the last block's. Compare with the plain ReLU stack at the same depth, whose first layer measures exactly 0.000e+00: this is the difference between an architecture that trains at depth 100 and one that does not.

The reason is visible in the forward expression. Because each block computes x plus something, the derivative of its output with respect to its input is 1 plus something. The chain rule multiplies those together, and a product of terms near 1 does not vanish the way a product of terms near 0.25 does. The gradient has a road straight back to the first layer that does not pass through any weight matrix.

How to use this on your own model#

  • Log the gradient norm per layer, not just the total. A single total hides the shape entirely; the per-layer profile is what tells you where learning has stopped.
  • A healthy profile is flat to within an order of magnitude or two across layers. A ratio of 1e6 between first and last means the early layers are effectively frozen.
  • Exactly zero anywhere means underflow or a dead path, not a small gradient. Look at initialisation, at a ReLU whose inputs are all negative, and at anything you detached.
  • Do this once on a few batches at the start of a run, not continuously. It costs a backward pass you were doing anyway plus one norm per layer.

Where people go wrong

Assuming a deep network is learning in every layer because the loss falls. Early layers can be nearly frozen while the last few do all the work.

If this comes up in an interview

What causes vanishing gradients and how are they addressed?

Backpropagation multiplies local derivatives layer by layer; if they are consistently below one, as with saturating activations or poorly scaled weights, gradients shrink exponentially towards early layers. Fixes are ReLU-family activations, careful initialisation, normalisation layers and residual connections.

Build this: Run the experiment above for depths 5, 20 and 50 with sigmoid, ReLU, ReLU with He initialisation, and ReLU with residual connections and layer norm. Plot first-layer gradient norm against depth for each.

This is one section of Backpropagation, 17 hours across 4 sections. Read it in full to get the whole topic in order.