ml-models / Mathematical foundations / The chain rule intro

The chain rule

A neural network is a long composition of simple functions, and the chain rule is how you differentiate a composition: multiply the local derivatives along the way. This page builds it from one variable to Jacobians, shows why running it backwards (reverse mode) is what makes training a billion-parameter model affordable, and differentiates a two-layer network by hand, checked against PyTorch's autograd at v2.15.0.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. The scalar chain rule
  5. The vector form: multiplying Jacobians
  6. Forward mode versus reverse mode
  7. Local gradients per operation
  8. How PyTorch packages a local rule
  9. Worked example
  10. Implementation
  11. Tradeoffs
  12. Check yourself
  13. Connections
  14. Read first
  15. Read next
  16. References

Problem

The previous page differentiated functions you could write in one line. A network's loss is nothing like that. It is loss(softmax(linear(relu(linear(x))))), dozens or hundreds of layers deep, with every weight buried somewhere in the middle. Writing out ∂loss/∂w for each weight as one formula is hopeless, and estimating it with finite differences would take a forward pass per weight.

We need a rule that builds the derivative of a composition out of the derivatives of its parts, and a way to apply it that costs about as much as computing the loss once.

Intuition

Rates of change multiply. If u changes 3 times as fast as x, and f changes 8 times as fast as u, then f changes 3 × 8 = 24 times as fast as x. A chain of functions is a chain of gears, and the overall ratio is the product of the ratios.

The order you multiply in doesn't change the answer, but it changes the work. Starting from the loss and walking back toward the inputs, you carry a single vector, "how much does the loss care about this value", and every layer updates it with one cheap product. That backward walk is reverse mode, and backpropagation is reverse mode applied to a network.

The chain rule on f(x) = (3x + 1)² A computation graph with three value nodes, x, u and f, joined by two operations, u = 3x + 1 and f = u squared. The forward pass, left to right, carries the values 1, 4 and 16. The backward pass, right to left, carries the derivatives of f: 1 at f, 8 at u after multiplying by the local derivative 2u, and 24 at x after multiplying by the local derivative 3. x x = 1 ∂f/∂x = 24 u u = 4 ∂f/∂u = 8 f f = 16 ∂f/∂f = 1 u = 3x + 1 f = u² × du/dx = 3 × df/du = 2u = 8

Forward (green) computes values; backward (amber) starts from ∂f/∂f = 1 and multiplies by each operation's local derivative on the way back. Each local derivative only needs values the forward pass already computed.

Mechanics

The scalar chain rule

f(x) = g(h(x))  ⇒  f′(x) = g′(h(x)) · h′(x),   or   df/dx = df/du · du/dx

For f(x) = (3x + 1)², let u = 3x + 1. Then du/dx = 3 and df/du = 2u, so df/dx = 2(3x + 1) · 3. At x = 1, u = 4 and df/dx = 8 · 3 = 24. Expanding (3x + 1)² = 9x² + 6x + 1 and differentiating directly gives 18x + 6 = 24 too; the chain rule just never needs the expansion.

When a value feeds into several places, its derivative is the sum over the paths: if f depends on x through both u and v, then df/dx = ∂f/∂u · du/dx + ∂f/∂v · dv/dx. This is why .grad accumulates: autograd adds up the contribution of each use.

The vector form: multiplying Jacobians

For vector-valued steps the derivatives are Jacobians and the chain rule becomes matrix multiplication. If x → u = h(x) → y = g(u), then

Jy←x = Jy←u · Ju←x    [m, n] = [m, k] · [k, n]

A network with L layers has L Jacobians to multiply. When the final output is a scalar loss, its Jacobian is a row vector, the gradient, and

∇xL = ∇yL · JL · JL−1 ⋯ J1

Forward mode versus reverse mode

Matrix products are associative, so that chain can be evaluated from either end.

  • Forward mode goes right to left in the formula, in the same order as the computation: push a tangent vector v through as J₁v, then J₂(J₁v), and so on. Each pass is a Jacobian-vector product (JVP) and gives the derivative of every output along one input direction. A full gradient needs one pass per input.
  • Reverse mode starts from the output and goes backwards: ∇yL · JL, then that times JL−1, and so on. Each pass is a vector-Jacobian product (VJP) and gives the derivative of one output with respect to every input. A full gradient needs one pass per output.

Training has millions of inputs (the parameters) and one output (the loss), so reverse mode wins by a factor of the parameter count. The price is memory: the backward pass needs the forward pass's intermediate values, so they are all kept until backward runs.

Forward mode and reverse mode on a 1000-1000-1000-1 network Two horizontal bars compare the multiply-adds needed for a full gradient of a network with 1000 inputs, two hidden layers of 1000, and one output. Forward mode needs 1000 passes of about two million multiply-adds each, roughly two billion in total. Reverse mode needs one pass, about two million. The reverse bar is a thin sliver beside the forward bar. Full gradient of a 1000–1000–1000–1 network (multiply-adds) forward mode 1000 passes · 2,001,000,000 reverse mode 1 pass · 2,001,000 one pass ≈ 2,001,000 multiply-adds, about one forward evaluation; the ratio is inputs ÷ outputs = 1000×

Forward mode pays once per input, reverse mode once per output. With one loss and a billion parameters the gap is a billion to one, which is why every training framework is built on reverse mode.

Local gradients per operation

Reverse mode never forms a Jacobian. Each operation only needs a rule that turns the gradient of its output into gradients of its inputs. For the operations in a two-layer network, with ḡ meaning ∂L/∂(that value):

OperationForwardBackward (given ȳ)
Lineary = Wx + bW̄ = ȳ xᵀ,   b̄ = ȳ,   x̄ = Wᵀ ȳ
ReLUy = max(0, z)z̄ = ȳ where z > 0, else 0
Dot producty = w · h + bw̄ = ȳ h,   h̄ = ȳ w,   b̄ = ȳ
Squared errorL = ½(y − t)²ȳ = y − t

Notice the transpose in x̄ = Wᵀȳ. Multiplying by a Jacobian's transpose is exactly a VJP, and for a linear layer the Jacobian is W itself.

How PyTorch packages a local rule

Every differentiable operation in PyTorch pairs a forward with a backward, the VJP rule. You can write your own by subclassing torch.autograd.Function: forward computes the output and stashes what backward needs with ctx.save_for_backward; backward receives the output's gradient and returns one gradient per input. The source notes that vjp = backward: they are the same thing. An optional jvp supplies the forward-mode rule. Calling loss.backward() runs torch.autograd.backward, which walks the recorded graph from the loss back to the leaves, calling each node's VJP.

TermShape/ParamsMeaningSource
Chain rule df/dx = df/du · du/dx The derivative of a composition is the product of local derivatives. —
Upstream gradient ȳ same shape as y ∂L/∂y, arriving at an operation during the backward pass. —
VJP v: [outputs] → [inputs] vᵀJ without building J; one reverse-mode step. autograd.functional.vjp
JVP v: [inputs] → [outputs] Jv without building J; one forward-mode step. autograd.functional.jvp, forward_ad.make_dual
Function static forward(ctx, …), backward(ctx, *grads) A differentiable operation with a hand-written VJP; call it through .apply. autograd.Function
ctx.save_for_backward tensors Keep forward values the VJP will need; read back as ctx.saved_tensors. FunctionCtx.save_for_backward
torch.autograd.backward tensors, grad_tensors Run reverse mode from the given outputs and accumulate into leaves' .grad. autograd.backward

Worked example

A two-layer network, by hand. Input x = [2, 1], first layer W₁ = [[1, -1], [-1, 1]] with bias b₁ = [1, 0], ReLU, then an output y = w₂ · h + b₂ with w₂ = [2, 3], b₂ = 0, and loss L = ½(y − t)² with target t = 3.

ForwardValueBackwardValue
z₁ = W₁x + b₁[2, -1]ȳ = y − t1
h = ReLU(z₁)[2, 0]w̄₂ = ȳ h,   b̄₂ = ȳ[2, 0], 1
y = w₂ · h + b₂4h̄ = ȳ w₂[2, 3]
L = ½(y − t)²0.5z̄₁ = h̄ where z₁ > 0[2, 0]
W̄₁ = z̄₁ xᵀ,   b̄₁ = z̄₁[[4, 2], [0, 0]], [2, 0]
x̄ = W₁ᵀ z̄₁[2, -2]

Read the backward column top to bottom. The output is 1 too high, so ȳ = 1. The output weight w̄₂ = ȳ h says to shrink the weight on the hidden unit that fired (2) and leave the one that didn't alone. The hidden unit with z₁ = -1 was switched off by ReLU, so the gradient through it is 0, and so is the whole second row of W̄₁: a dead ReLU passes no gradient.

Forward values and backward gradients through the two-layer network Five stages from left to right: x, z1, h, y and L. Above each, in green, its forward value: x is [2, 1], z1 is [2, −1], h is [2, 0], y is 4 and L is 0.5. Below each, in amber, the gradient of L with respect to it: 1 at L, 1 at y, [2, 3] at h, [2, 0] at z1 and [2, −2] at x. The ReLU between z1 and h is highlighted where it zeroes the second component of the gradient. x [2, 1] [2, -2] W₁x + b₁ z₁ [2, -1] [2, 0] ReLU h [2, 0] [2, 3] w₂ · h + b₂ y 4 1 ½(y − t)² L 0.5 1 ReLU zeroes the gradient where z₁ ≤ 0 forward values backward: ∂L/∂(·)

Check it. Nudge W₁[0][0] from 1 to 1.001. Then z₁[0] becomes 2.002, y becomes 4.004 and L becomes ½(1.004)² ≈ 0.504, a change of about 0.004, which is 0.001 × W̄₁[0][0] = 0.001 × 4. The tests behind this page check every gradient in the table this way, by finite differences.

Implementation

The same network in PyTorch; autograd reproduces every number in the table:

import torch

x  = torch.tensor([2.0, 1.0], requires_grad=True)
W1 = torch.tensor([[1.0, -1.0], [-1.0, 1.0]], requires_grad=True)
b1 = torch.tensor([1.0, 0.0], requires_grad=True)
w2 = torch.tensor([2.0, 3.0], requires_grad=True)
b2 = torch.tensor(0.0, requires_grad=True)

h = torch.relu(W1 @ x + b1)        # [2, 0]
y = w2 @ h + b2                    # 4
loss = 0.5 * (y - 3.0) ** 2        # 0.5
loss.backward()

W1.grad   # tensor([[4., 2.], [0., 0.]])
w2.grad   # tensor([2., 0.])
x.grad    # tensor([ 2., -2.])

A custom operation with a hand-written VJP, the way PyTorch's own operations are built:

class Square(torch.autograd.Function):
    @staticmethod
    def forward(ctx, u):
        ctx.save_for_backward(u)
        return u * u

    @staticmethod
    def backward(ctx, grad_output):    # grad_output = ∂L/∂(u²)
        (u,) = ctx.saved_tensors
        return grad_output * 2 * u     # ∂L/∂u, by the chain rule

x = torch.tensor(1.0, requires_grad=True)
Square.apply(3 * x + 1).backward()
x.grad                                  # tensor(24.)
torch.autograd.gradcheck(Square.apply, torch.randn(3, dtype=torch.double, requires_grad=True))

Tradeoffs

  • Reverse mode trades memory for speed. One backward pass costs about two forward passes, but every intermediate the VJPs need stays in memory until it runs. Activation memory often limits batch size more than the weights do. Gradient checkpointing recomputes some activations during backward to save memory.
  • Forward mode still has uses. With few inputs and many outputs, such as the sensitivity of a whole output to one hyperparameter, or Jacobian-vector products inside second-order methods, forward mode is cheaper and needs no stored graph.
  • Long products shrink or blow up. A gradient that passes through 100 layers is a product of 100 Jacobians. If their typical scale is below 1 it vanishes; above 1 it explodes. Initialization, normalization and residual connections exist largely to keep that product near 1; the backpropagation page covers it.
  • Hand-written backward functions are a bug source. A wrong VJP trains silently worse instead of crashing. Always check a custom backward with gradcheck, which compares it to finite differences in double precision.

Check yourself

What is d/dx of e2x at x = 0?

Let u = 2x. d(eᵘ)/du = eᵘ and du/dx = 2, so the derivative is 2e2x, which is 2 at x = 0.

A function maps 10 inputs to 10,000 outputs. Forward or reverse mode for the full Jacobian?

Forward mode: it needs one pass per input (10), against one per output (10,000) for reverse mode.

In the worked example, why is the second row of W̄₁ all zeros?

The second hidden unit had z₁ = −1, so ReLU output 0 and its local derivative is 0. Nothing upstream of it receives gradient through that unit, so its weights don't move this step.

For y = Wx with W of shape [3, 5], what shape is x̄, and how is it computed?

x̄ has x's shape, [5]. It is Wᵀȳ: a [5, 3] matrix times the [3] upstream gradient.

Connections

References

  • Goodfellow, Bengio and Courville. Deep Learning, section 6.5 (back-propagation and other differentiation algorithms). MIT Press, 2016.
  • Rumelhart, Hinton and Williams. Learning representations by back-propagating errors. Nature 323, 1986.
  • Baydin et al. Automatic Differentiation in Machine Learning: a Survey. JMLR 2018. arXiv:1502.05767
  • Griewank and Walther. Evaluating Derivatives: Principles and Techniques of Algorithmic Differentiation, 2nd ed. SIAM, 2008.
  • PyTorch v2.15.0: torch.autograd.Function and torch.autograd.backward.