Why AD Exists#
Every optimiser in this course needs gradients. How do we get them?
- Numerical differentiation: . Simple but evaluations per gradient and suffers from truncation vs roundoff trade-off. Unacceptable for parameters.
- Symbolic differentiation: Apply calculus rules to the closed-form expression of . Produces exact derivatives but suffers from expression swell — the derivative expression can be exponentially larger than the original function. Impractical for programs with loops and conditionals.
- Automatic (algorithmic) differentiation (AD): Compute derivatives by applying the chain rule to the sequence of elementary operations that the program executes. AD evaluates the derivative at a specific numeric input, producing exact (to machine precision) derivatives at a cost proportional to evaluating the function itself. This is what PyTorch, JAX, and TensorFlow do.
AD works because every computer program — no matter how complex — is a composition of elementary operations (, etc.) whose derivatives are known. The chain rule threads through this computational graph.
Forward Mode#
Forward-mode AD propagates derivatives from inputs to outputs. Associate each variable with a dual number (its derivative with respect to a chosen input direction). For each elementary operation , compute:
Initialise for the input of interest and for all others.
Worked example — Dual numbers for at :
The elementary operations are , . With :
- ,
- ,
Analytic check: , at : . Matches.
Cost of forward mode. For , computing the full Jacobian requires forward passes (one per input dimension). Forward mode is efficient when (few inputs, many outputs).
Reverse Mode#
Reverse-mode AD (a.k.a. backpropagation) propagates derivatives from outputs back to inputs. For a scalar output , this computes the full gradient in a single backward pass.
The algorithm works in two phases:
- Forward pass: Evaluate the function, recording the computational graph (the "tape") — store every intermediate value and the operations that produced them.
- Backward pass: Traverse the graph in reverse topological order. For each operation , having already computed , compute the adjoint of each input:
accumulating contributions from all uses of . At the end, for all .
The backward pass computes a vector-Jacobian product (VJP): given , compute , where is the Jacobian of . The full gradient is the VJP of the scalar with respect to all parameters.
Worked Micro-Graph#
Consider with inputs .
Forward pass (building the graph):
- (output )
Backward pass:
- (seed)
- Operation : , so
- Operation :
- , so
- , so
- Operation :
- , so
- , so
Analytic check: ✓. ✓. ✓.
Complexity#
Reverse mode for scalar-output functions (the case for most ML losses):
where is a small constant (typically –). The gradient of a scalar function of variables costs roughly the same as evaluating the function itself — independent of . This is the cheap gradient principle, and it is why deep learning with millions of parameters is computationally feasible.
The trade-off:
- If (inputs) (outputs), use reverse mode. One backward pass computes all for a scalar , vs forward passes.
- If , use forward mode. Each forward pass computes one column of the Jacobian.
- Deep learning: (parameters) is enormous, (scalar loss). Reverse mode wins by a factor of .
Memory cost. The forward pass must store all intermediate activations for the backward pass (or recompute them). For deep nets with large activations, this is the primary memory bottleneck — not the parameters themselves. Gradient checkpointing trades memory for compute by selectively recomputing activations during the backward pass.
In reverse-mode AD on the graph $w = u^2$, if the adjoint $\bar{w} = 3$, what is $\bar{u}$?
When is forward-mode AD preferred over reverse mode?
Browser lab#
Implement forward-mode AD (dual numbers) for and compare with analytic derivative. Then implement manual reverse-mode AD on the micro-graph .
import numpy as np
# --- Forward-mode AD (dual numbers) ---
def forward_sin_x2(x):
"""Compute sin(x^2) and its derivative using dual numbers."""
# v1 = x^2
v1 = x**2
dv1 = 2 * x * 1.0 # dx = 1 (seeded)
# v2 = sin(v1)
v2 = np.sin(v1)
dv2 = np.cos(v1) * dv1
return v2, dv2
x_vals = np.linspace(0.5, 3, 50)
fwd_vals = np.array([forward_sin_x2(x) for x in x_vals])
# Analytic: d/dx sin(x^2) = 2x * cos(x^2)
analytic_vals = 2 * x_vals * np.cos(x_vals**2)
print("Forward-mode AD: sin(x^2) and derivative")
for x in [1.0, 2.0, 3.0]:
f, df = forward_sin_x2(x)
an_df = 2 * x * np.cos(x**2)
print(f" x={x:.1f}: f={f:.6f}, AD df={df:.6f}, analytic={an_df:.6f}")
# --- Reverse-mode AD on f = (a*b + c)^2 ---
def reverse_micro(a, b, c):
"""Manual reverse-mode on f = (a*b + c)^2."""
# Forward pass
v1 = a * b # v1 = a * b
v2 = v1 + c # v2 = v1 + c
v3 = v2**2 # f = v3
# Backward pass
d_v3 = 1.0 # seed
d_v2 = d_v3 * 2 * v2 # d(v3)/d(v2) = 2*v2
d_v1 = d_v2 * 1.0 # d(v2)/d(v1) = 1
d_c = d_v2 * 1.0 # d(v2)/d(c) = 1
d_a = d_v1 * b # d(v1)/d(a) = b
d_b = d_v1 * a # d(v1)/d(b) = a
return v3, d_a, d_b, d_c
a, b, c = 1.0, 2.0, 0.5
f, da, db, dc = reverse_micro(a, b, c)
print(f"\nReverse-mode on f = (a*b + c)^2 with (a={a}, b={b}, c={c})")
print(f" f = {f:.4f}")
print(f" df/da = {da:.4f} (analytic: {2*(a*b + c)*b:.4f})")
print(f" df/db = {db:.4f} (analytic: {2*(a*b + c)*a:.4f})")
print(f" df/dc = {dc:.4f} (analytic: {2*(a*b + c):.4f})")