ml-models / Neural network fundamentals / Backpropagation core

Backpropagation

Backpropagation is the chain rule run backwards through a network, reusing every intermediate value the forward pass computed. This page trains a small network for one step with every number shown, catalogues the local gradient of each common operation, explains why gradients vanish or explode in deep networks, and shows how to check a backward pass with finite differences, the way torch.autograd.gradcheck does at v2.15.0.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. The algorithm
  5. With a batch
  6. Local gradients per operation
  7. Time and memory
  8. Vanishing and exploding gradients
  9. Gradient checking
  10. In PyTorch
  11. Worked example
  12. Implementation
  13. Tradeoffs
  14. Check yourself
  15. Connections
  16. Read first
  17. Read next
  18. References

Problem

Training needs ∂L/∂θ for every parameter θ, at every step. A modern network has millions or billions of parameters. Estimating each derivative by nudging that parameter and re-running the network would cost one forward pass per parameter: a billion forward passes for one step.

Backpropagation gets all of them for the price of about one extra forward pass. The chain rule page showed why: reverse mode differentiation sends one gradient backwards from the scalar loss, and each operation turns the gradient of its output into the gradients of its inputs. This page is about doing that for a real network: with a batch, a softmax output and a parameter update; with dozens of layers, where the backward product can shrink to nothing or blow up; and with a way to know your gradients are right.

Intuition

The forward pass computes the network's prediction and the loss, layer by layer, and keeps the intermediate values. The backward pass walks the same layers in reverse. At each step it holds one quantity, the gradient of the loss with respect to that layer's output, which says "if this value went up a little, the loss would change by this much". Each layer uses its saved inputs to turn that into two things: the gradient for its own parameters (which the optimizer uses) and the gradient for its input (which it hands to the layer below).

Credit assignment is the point. The output layer knows directly how wrong it was. A hidden unit deep in the network only learns how much it contributed through the weights that connect it to the output, and a ReLU unit that was switched off contributed nothing, so it gets no gradient.

Forward values and backward gradients in a 2-3-2 network A network with 2 inputs, 3 hidden ReLU units and 2 output logits feeding a softmax cross-entropy loss. Inputs are [1, 2]. Hidden pre-activations z are [1, 1, -1] and after ReLU h is [1, 1, 0]. The logits are [0, 1], the softmax probabilities [0.269, 0.731], and with target class 0 the loss is 1.313. Going backwards, the gradient at the logits is [-0.731, 0.731] (probabilities minus the one-hot target), and at the hidden pre-activations it is [-0.731, 1.462, 0]. The third hidden unit has z = −1, so ReLU switched it off and its gradient is 0. x1 = 1 x2 = 2 z 1 → h 1 z̄ = -0.731 z 1 → h 1 z̄ = 1.462 z -1 → h 0 z̄ = 0 (dead) s 0, p 0.269 s̄ = -0.731 s 1, p 0.731 s̄ = 0.731 L = 1.313 L̄ = 1 target class 0 hidden: Linear(2, 3) + ReLU logits: Linear(3, 2) forward value backward: ∂L/∂(·)

Forward values in green, the gradient of the loss with respect to each value in amber. The dashed hidden unit had a negative input, so ReLU blocked it in both directions: its output was 0 and its gradient is 0, and the faint weights leaving it get no update.

Mechanics

The algorithm

Write the network as a chain of operations v1 = f1(x), v2 = f2(v1), …, L = fK(vK−1), each with its own parameters. Write v̄ for ∂L/∂v, a tensor with the same shape as v.

  1. Forward. Compute each vk in order, saving whatever each operation's backward rule will need (usually its inputs).
  2. Seed. Set L̄ = 1.
  3. Backward. For k = K down to 1, apply fk's local rule: from v̄k and the saved values, compute v̄k−1 and the gradient of fk's parameters.
  4. Accumulate. If a value feeds several operations (a residual connection, a shared weight, an embedding used twice), its gradient is the sum of the gradients arriving from each use.

On a general graph, "in reverse" means reverse topological order: a node's backward rule runs only after every operation that used its output has sent its gradient back. That is the whole algorithm. Everything else is the catalogue of local rules and engineering.

With a batch

Real training uses a batch of N examples at once. For a linear layer Y = XWᵀ + b with X of shape (N, din) and W of shape (dout, din), the rules become matrix products:

X̄ = Ȳ W,    W̄ = Ȳᵀ X,    b̄ = Σrows Ȳ

Check the shapes and you can rederive them: W̄ must be (dout, din), and Ȳᵀ X is the only product of the available tensors with that shape. The product Ȳᵀ X sums over the batch dimension, so a parameter's gradient adds up every example's contribution, and the bias, broadcast across rows in the forward pass, gets a sum over rows in the backward pass. If the loss is a mean over the batch (PyTorch's default reduction="mean"), Ȳ already carries the 1/N.

Local gradients per operation

Every operation needs a backward rule, a vector-Jacobian product that never builds the Jacobian. These cover most of a standard network (⊙ is element-wise multiplication):

OperationForwardBackward (given ȳ)Saves
Addy = a + bā = ȳ,   b̄ = ȳnothing
Multiplyy = a ⊙ bā = ȳ ⊙ b,   b̄ = ȳ ⊙ aa, b
Broadcasty = b, copied over rowsb̄ = sum of ȳ over the copied axisshape
LinearY = XWᵀ + bX̄ = ȲW,   W̄ = ȲᵀX,   b̄ = ΣrowsȲX, W
ReLUy = max(0, z)z̄ = ȳ ⊙ [z > 0]z (or a mask)
Sigmoidy = σ(z)z̄ = ȳ ⊙ y(1 − y)y
Tanhy = tanh zz̄ = ȳ ⊙ (1 − y²)y
Softmax + cross-entropyL = −log softmax(s)ts̄ = softmax(s) − onehot(t)probabilities
MeanL = (1/N) Σ ℓiℓ̄i = 1/NN

Two patterns are worth remembering. Add copies its gradient to both inputs unchanged, which is why residual connections are such good gradient highways. And the fused softmax-plus-cross-entropy rule, "probabilities minus target", is both simpler and more numerically stable than chaining the two separate rules, which is why PyTorch's cross_entropy takes logits (see loss functions).

Time and memory

The backward pass of a linear layer does two matrix multiplies (X̄ and W̄) for the forward pass's one, so a training step costs about three forward passes: the standard estimate is 6 floating-point operations per parameter per training token, against 2 for inference alone.

Memory is the harder constraint. Every value a backward rule needs must stay alive from the moment the forward pass creates it until the backward pass consumes it, so activation memory grows with depth × batch size × width. That is why inference (inside torch.no_grad(), which records nothing) fits much larger batches than training, and why large models use activation checkpointing: save only some layers' inputs and recompute the rest during the backward pass, trading about one extra forward pass for a large cut in memory.

Vanishing and exploding gradients

Going down through layer ℓ multiplies the gradient by that layer's transposed Jacobian: h̄ℓ−1 = Wℓᵀ (h̄ℓ ⊙ φ′(zℓ)). After L layers the gradient at the input is a product of L such factors. If each factor shrinks vectors by a typical ratio r, the gradient scales like rL: with r = 0.7 and 30 layers that is 2 × 10−5; with r = 1.4 it is 2 × 104. Early layers then learn nothing (vanishing), or updates are so large that training diverges and the loss turns into nan (exploding).

Two things set r. The weight scale: a matrix with entries of variance σ²/n scales a vector's length by about σ. And the activation's slope: ReLU passes about half the units with slope 1, so it halves the squared length, which the factor √2 in He initialization exactly compensates. The sigmoid's slope is at most ¼, so a sigmoid network shrinks the gradient by at least 4 per layer whatever the weights; this is why deep networks moved from sigmoid and tanh to ReLU.

Gradient size at each layer of a 30-layer network, on a log scale The size of the gradient at each layer of a 30-layer network of width 32, relative to the gradient arriving at the top layer, drawn on a logarithmic axis from 10 to the minus 20 up to 10 to the 5. All four networks share the same random weights, only rescaled. ReLU, gain 0.7: at the input the gradient is 3.1e-05 times its size at the top. ReLU, He init: at the input the gradient is 1.4e+00 times its size at the top. ReLU, gain 1.4: at the input the gradient is 3.3e+04 times its size at the top. sigmoid, Xavier init: at the input the gradient is 3.3e-20 times its size at the top. 10-20 10-15 10-10 10-5 100 105 0 10 20 30 ReLU, gain 0.7 ReLU, He init ReLU, gain 1.4 sigmoid, Xavier init layer (the backward pass runs from layer 30, right, to the input, left)

Same weights, four scalings. Only He initialization with ReLU keeps the gradient near 1 all the way down (1.4e+00 at the input). Scale the weights by 0.7 or 1.4 and the gradient shrinks to 3.1e-05 or grows to 3.3e+04; the sigmoid network is at 3.3e-20.

The fixes are now standard, and most of them get their own page: careful initialization (Xavier/Glorot for tanh, He/Kaiming for ReLU) so that r ≈ 1 at the start; ReLU-family activations, which don't saturate for positive inputs; normalization layers, which reset each layer's scale; residual connections, which make each layer compute x + f(x) so its Jacobian is I + ∂f/∂x and the gradient always has a path that isn't multiplied down; and gradient clipping (clip_grad_norm_), which rescales the whole gradient when its norm exceeds a limit, as a guard against the occasional explosive step. Recurrent networks, which apply the same matrix at every time step, suffer the worst version of this, which is what the LSTM was invented to fix.

Gradient checking

A wrong backward rule doesn't crash: the network still trains, just badly, and the bug can hide for weeks. The test is to compare each analytic gradient with a finite difference. The forward difference (L(θ + h) − L(θ))/h has error proportional to h; the central difference

∂L/∂θ ≈ (L(θ + h) − L(θ − h)) / 2h

has error proportional to h², because the second-order terms cancel. But h can't go to zero: each loss is only accurate to about 10−16 of its size in float64, so the rounding error of the difference grows like 10−16/h. The best h balances the two, and the result is a V shape. Compare with the relative error |a − n| / max(|a|, |n|), not the absolute one, so that large and small gradients are judged alike; in float64, a relative error below about 10−7 means the backward pass is right, and one near 10−2 means a bug.

Finite-difference error against step size, on log-log axes The relative error between backpropagation's gradient for the first weight of the network above, -0.731, and two finite-difference estimates, for step sizes h from 10 to the minus 12 up to 10 to the minus 1. From the right, the forward difference's error falls in proportion to h and the central difference's error in proportion to h squared, until rounding error takes over and both rise again as h shrinks further. The central difference is best at h = 1e-05, with relative error 1.4e-11. 10-12 10-10 10-8 10-6 10-4 10-2 10-10 10-8 10-6 10-4 10-2 forward difference, error ∝ h central difference, error ∝ h² rounding error ∝ 1/h step size h (float64); vertical: relative error against backprop

Too large an h measures curvature, too small an h measures rounding. Central differences reach about 10−11 here at h around 10−5; in float32, rounding is 109 times worse, which is why gradient checks run in float64.

Two cautions. Kinks: if a ReLU input sits within h of zero, the two evaluations straddle the kink and the check fails even for a correct backward pass, so check at points away from kinks or ignore isolated failures. And cost: one check needs two forward passes per parameter, so check a tiny network, or a random sample of parameters, never a full model. PyTorch's torch.autograd.gradcheck does exactly this, perturbing each input entry by eps=1e-6 with central differences, requiring float64 inputs, and comparing the numerical and analytic Jacobians with atol=1e-5 and rtol=1e-3.

In PyTorch

PyTorch builds the graph while the forward pass runs: every operation on a tensor that requires grad records a node holding its backward rule and saved tensors (the chain rule page shows an autograd.Function written by hand). loss.backward() calls torch.autograd.backward, which hands the graph to the C++ engine. The engine runs the nodes in dependency order and, at each leaf tensor (a parameter), adds the result into .grad. Adding rather than overwriting is what makes shared weights and gradient accumulation work, and it's why every training loop calls optimizer.zero_grad() (or model.zero_grad()) before the next backward pass. After backward, the graph's saved tensors are freed; calling backward through it a second time needs retain_graph=True.

TermShape/ParamsMeaningSource
Tensor.backward scalar loss; retain_graph Starts the backward pass from this tensor with seed 1. _tensor.py
torch.autograd.backward tensors, grad_tensors Accumulates gradients of the given outputs into every leaf's .grad. autograd/__init__.py
torch.autograd.grad outputs, inputs Returns the gradients instead of accumulating them into .grad. autograd/__init__.py
_engine_run_backward outputs, gradients The handoff from Python to the C++ engine that walks the graph. autograd/graph.py
Function.backward ctx, one gradient per output A local backward rule (a vector-Jacobian product), as a custom op defines it. function.py
ctx.save_for_backward tensors Keeps forward values alive for the backward rule; this is the activation memory. function.py
torch.autograd.gradcheck eps=1e-6, atol=1e-5, rtol=1e-3 Compares analytic and central-difference Jacobians, in float64. gradcheck.py
clip_grad_norm_ max_norm, norm_type=2 Rescales all gradients together if their total norm exceeds max_norm. clip_grad.py
Module.zero_grad set_to_none=True Clears accumulated .grad before the next backward pass. module.py

Worked example

One training step, every number. Input x = [1, 2], target class t = 0. First layer W1 = [[1, 0], [-1, 1], [0.500, -1]], b1 = [0, 0, 0.500], then ReLU; output layer W2 = [[1, -1, 2], [0, 1, 1]], b2 = [0, 0]; loss L = −log softmax(s)t.

ForwardValueBackwardValue
z = W1x + b1[1, 1, -1]s̄ = p − onehot(t)[-0.731, 0.731]
h = ReLU(z)[1, 1, 0]W̄2 = s̄ hᵀ,   b̄2 = s̄[[-0.731, -0.731, 0], [0.731, 0.731, 0]]
s = W2h + b2[0, 1]h̄ = W2ᵀ s̄[-0.731, 1.462, -0.731]
p = softmax(s)[0.269, 0.731]z̄ = h̄ ⊙ [z > 0][-0.731, 1.462, 0]
L = −log pt1.313W̄1 = z̄ xᵀ,   b̄1 = z̄[[-0.731, -1.462], [1.462, 2.924], [0, 0]]

The network gives the right class only 0.269 probability, so s̄ pushes the correct logit up (negative gradient) and the wrong one down. h̄ = W2ᵀ s̄ is each hidden unit's share of the blame: unit 2 feeds the wrong logit with weight 1 and the right one with −1, so it gets the largest gradient, 1.462. Unit 3 would have gotten -0.731, but its z = -1 was negative, ReLU zeroed it, and the third rows of W̄1 and b̄1 are 0.

Now take a gradient-descent step with learning rate 0.1: θ ← θ − 0.1 θ̄. The first layer becomes W1 = [[1.073, 0.146], [-1.146, 0.708], [0.500, -1]], and running the forward pass again gives z = [1.439, 0.123, -1], logits [1.503, -0.065], p = [0.827, 0.173] and loss 0.189, down from 1.313. Unit 3 is still off, so its weights didn't move.

Checking it. Nudge every one of the 17 parameters by ±10−6 and compare the central difference with the table: the largest relative error is 2.1e-10.

Implementation

import torch
from torch import nn
import torch.nn.functional as F

model = nn.Sequential(nn.Linear(2, 3), nn.ReLU(), nn.Linear(3, 2))
with torch.no_grad():
    model[0].weight.copy_(torch.tensor([[1., 0.], [-1., 1.], [0.5, -1.]]))
    model[0].bias.copy_(torch.tensor([0., 0., 0.5]))
    model[2].weight.copy_(torch.tensor([[1., -1., 2.], [0., 1., 1.]]))
    model[2].bias.zero_()

x, t = torch.tensor([[1., 2.]]), torch.tensor([0])
loss = F.cross_entropy(model(x), t)       # 1.3133
loss.backward()                           # fills .grad on all four parameters
model[0].weight.grad                      # [[-0.731, -1.462], [1.462, 2.924], [0, 0]]

with torch.no_grad():                     # one SGD step, lr = 0.1
    for p in model.parameters():
        p -= 0.1 * p.grad
model.zero_grad()                         # .grad accumulates; clear it
F.cross_entropy(model(x), t)              # 0.1894

# gradient checking a small function in float64
def f(w):
    return F.cross_entropy(torch.relu(x.double() @ w.T) @ model[2].weight.double().T, t)
w = model[0].weight.detach().double().requires_grad_()
torch.autograd.gradcheck(f, (w,))         # True

The backward pass by hand, for a batch, in plain Python:

import math

def matmul(a, b):
    return [[sum(x * y for x, y in zip(row, col)) for col in zip(*b)] for row in a]

def transpose(m):
    return [list(col) for col in zip(*m)]

def forward_backward(X, T, W1, b1, W2, b2):
    N = len(X)
    Z = [[z + b for z, b in zip(row, b1)] for row in matmul(X, transpose(W1))]
    H = [[max(0.0, z) for z in row] for row in Z]
    S = [[s + b for s, b in zip(row, b2)] for row in matmul(H, transpose(W2))]
    P = [[math.exp(s) / sum(math.exp(v) for v in row) for s in row] for row in S]
    loss = -sum(math.log(P[i][T[i]]) for i in range(N)) / N
    dS = [[(p - (k == T[i])) / N for k, p in enumerate(P[i])] for i in range(N)]
    dW2, db2 = matmul(transpose(dS), H), [sum(c) for c in zip(*dS)]
    dH = matmul(dS, W2)
    dZ = [[g if z > 0 else 0.0 for g, z in zip(gr, zr)] for gr, zr in zip(dH, Z)]
    dW1, db1 = matmul(transpose(dZ), X), [sum(c) for c in zip(*dZ)]
    return loss, (dW1, db1, dW2, db2)

forward_backward([[1, 2]], [0], [[1, 0], [-1, 1], [0.5, -1]], [0, 0, 0.5],
                 [[1, -1, 2], [0, 1, 1]], [0, 0])[0]       # 1.3133

Tradeoffs

  • Reverse mode versus finite differences. Backprop gives every gradient for about three forward passes, exactly up to rounding; finite differences cost two forward passes per parameter and are only approximate. Finite differences are a test, never a training method.
  • Compute versus memory. Saving every activation is fastest; activation checkpointing recomputes them to save memory. Large-model training almost always checkpoints, paying roughly a third more compute.
  • Fused versus composed rules. Writing softmax and cross-entropy as one operation gives a simpler, more stable gradient than chaining their rules; the same is true of log-sum-exp, attention and normalization layers, which is why frameworks ship fused kernels.
  • Depth costs gradient health. Each layer multiplies the gradient by another Jacobian. Depth is only usable with the right initialization, activations, normalization and residual connections, and gradient clipping as a last guard.
  • Biological plausibility. Backprop needs the exact transposed weights on the way back and a global error signal, which real neurons don't obviously have. That matters for neuroscience, not for engineering: no alternative training rule matches it at scale.

Check yourself

A weight matrix is used twice in the forward pass (for example tied input and output embeddings). What is its gradient?

The sum of the gradients from both uses. PyTorch does this automatically by accumulating into .grad.

For Y = XWᵀ with X of shape (32, 100) and W of shape (10, 100), what are the shapes of Ȳ and W̄, and what is W̄?

Ȳ is (32, 10), like Y. W̄ = ȲᵀX is (10, 32) × (32, 100) = (10, 100), like W; the product sums over the 32 examples.

Your training loop forgets optimizer.zero_grad(). What happens?

Each backward() adds to the previous gradients, so step k uses the sum of all k gradients so far. Updates grow and point in a stale direction, and training usually diverges.

A gradient check gives relative error 10−2 for every weight in one layer and 10−9 everywhere else. What's likely wrong?

That layer's backward rule: a missing term, a missing transpose, or a missing 1/N. Uniformly large errors in one place are a bug; a few scattered failures near ReLU kinks are not.

Why does a 50-layer sigmoid network barely train its first layers, even with good initialization?

The sigmoid's slope is at most ¼, so each layer shrinks the gradient by at least 4 on top of the weights' effect: after 50 layers that is a factor of 450 ≈ 1030.

Connections

References

  • Linnainmaa. The Representation of the Cumulative Rounding Error of an Algorithm as a Taylor Expansion of the Local Rounding Errors. Master's thesis, University of Helsinki, 1970 (reverse-mode differentiation).
  • Werbos. Beyond Regression: New Tools for Prediction and Analysis in the Behavioral Sciences. PhD thesis, Harvard, 1974.
  • Rumelhart, Hinton and Williams. Learning Representations by Back-Propagating Errors. Nature, 1986.
  • Hochreiter. Untersuchungen zu dynamischen neuronalen Netzen. Diploma thesis, TU Munich, 1991; Bengio, Simard and Frasconi. Learning Long-Term Dependencies with Gradient Descent is Difficult. IEEE Transactions on Neural Networks, 1994 (vanishing gradients).
  • Glorot and Bengio. Understanding the Difficulty of Training Deep Feedforward Neural Networks. AISTATS, 2010.
  • He, Zhang, Ren and Sun. Delving Deep into Rectifiers. ICCV, 2015 (arXiv 1502.01852).
  • Pascanu, Mikolov and Bengio. On the Difficulty of Training Recurrent Neural Networks. ICML, 2013 (arXiv 1211.5063; gradient clipping).
  • Chen, Xu, Zhang and Guestrin. Training Deep Nets with Sublinear Memory Cost. 2016 (arXiv 1604.06174; activation checkpointing).
  • Baydin, Pearlmutter, Radul and Siskind. Automatic Differentiation in Machine Learning: a Survey. JMLR, 2018 (arXiv 1502.05767).
  • PyTorch v2.15.0: torch/autograd/__init__.py, torch/autograd/function.py and torch/autograd/gradcheck.py.