Derivatives are local measurements; backprop is bookkeeping
Rebuild the chain rule from local sensitivities through scalar autograd, matrix pullbacks and checkpointing.
Original publication date unrecordedUpdated
Lesson preparation & details
Level: beginner
By the end, you should be able to
- Derive local sensitivities, shared-path and matrix gradients
- Extend a scalar reverse-mode engine and verify every small matrix coordinate
- Distinguish autograd modes, higher derivatives and checkpoint memory models
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 38 sections
Course map · DL004 · Calculus and automatic differentiation
The story
A parameter is a knob; the loss is a measurement downstream. The derivative asks how much that measurement changes locally per unit movement of the knob. The chain rule relates the knobs even when several operations separate them. This is the mathematical core of backpropagation, not a claim that an entire framework is a tiny scalar class.
We will derive a derivative from a limit, multiply sensitivities along a chain, add contributions at a branch, and implement a reverse traversal. Then we will test matrix gradients, higher derivatives and checkpointing on CPU. None of these experiments trains a language model.
Prerequisites
Use DL001–003's arrays, matrix products, singleton axes and reductions. A gradient of a scalar with respect to a tensor has that tensor's shape. Keep that rule beside the page, but remember that shape agreement alone cannot prove a derivative correct.
1 · Derivative — what is it, really?
For a differentiable scalar function,
The derivative's units are output units divided by input units: joules per metre, for example. It is a local exchange rate, like how many extra lumens a dimmer produces per degree at its present setting. A positive derivative says a sufficiently small negative perturbation lowers a smooth function when the derivative is nonzero. It does not authorize an arbitrarily large step.
1.1 Numeric check — the finite difference
For , the forward quotient is , so its truncation error is exactly . The centred quotient cancels this term. More generally, Taylor expansion gives a centred error of order for a sufficiently smooth function. Roundoff introduces an opposing error that grows roughly as machine precision divided by , with scale-dependent constants. There is no universally optimal 1e-5.
For at , the derivative is and the exact centred quotient is . A tiny step eventually subtracts nearly equal floating-point values and can get worse. Hold random inputs and masks fixed; test several step sizes. At a kink such as absolute value or ReLU at zero, a centred difference can average two slopes without being a classical derivative.
1.2 Building blocks — memorise these six
A constant differentiates to zero; to on its valid domain; to itself; natural log to for positive real ; a sum to the sum of derivatives; a product to . The chain rule composes these rules. Domains matter: an invalid logarithm in the forward pass is not repaired by a formally correct backward formula.
1.3 A worked example — one variable
For , the three terms contribute , and . Thus . The constant changes the function value but not its slope. This distinction explains why subtracting a loss value from a weight is not gradient descent: the loss and its sensitivity are different quantities with different units.
2 · The chain rule — the star of the show
For , . Evaluate the outer derivative at the inner value, not at the original input. Leibniz notation is a useful mnemonic; it is not permission to cancel arbitrary partial-derivative symbols as fractions.
2.1 Two-step example
Let and . At , , the outer sensitivity is 8 and the inner sensitivity is 3. Their product is 24. The complete derivative is , not .
2.2 Three-step chain — closer to what you'll see in a neural net
For , and , the successive derivatives are , and . Therefore . This is a log-probability derivative; a negative log-likelihood reverses its sign. At , , so the log-probability derivative is 1.
2.3 The sigmoid-derivative derivation (do this once)
Differentiate : the outer reciprocal gives and the inner derivative gives . The negatives cancel. Factor the result as
The maximum is at . That is a bound on this local activation derivative, not the full derivative through arbitrary weight matrices.
3 · Partial derivatives — multi-input functions
For , hold the other independent coordinate fixed: , . At the gradient is . For a unit Euclidean direction , the directional derivative is ; Cauchy–Schwarz makes the normalized gradient steepest ascent in that norm. Other norms or parameterizations change the steepest direction.
3.1 Chain rule with multiple variables
If , and , there are two paths from to . Add their contributions: . A shared value must receive all downstream contributions. The gradient is local, not a compass pointing at the global minimum: at on , the gradient is while the direction to the origin is . The normalized descent direction has cosine about 0.714 with the direction to the minimum, not 1.
4 · Computation graphs — a picture worth the algebra
A graph records values and their dependencies. One may draw operations as nodes or as labeled edges; be consistent. Reverse traversal accumulates sensitivities after all downstream uses have contributed.
4.1 The graph
For , introduce , , , . Data need not be optimized, but derivatives with respect to data still exist and are useful for sensitivity analysis.
Figure text equivalent — labels and relationships
- Node “w” (square).
- Node “z1 = w times x” (square).
- Node “x” (square).
- Node “z2 = z1 plus b” (square).
- Node “b” (square).
- Node “z3 = z2 minus y” (square).
- Node “target y” (square).
- Node “L = z3 squared” (square).
- “w” leads to “z1 = w times x”. Line: normal; ends: arrow point.
- “x” leads to “z1 = w times x”. Line: normal; ends: arrow point.
- “z1 = w times x” leads to “z2 = z1 plus b”. Line: normal; ends: arrow point.
- “b” leads to “z2 = z1 plus b”. Line: normal; ends: arrow point.
- “z2 = z1 plus b” leads to “z3 = z2 minus y”. Line: normal; ends: arrow point.
- “target y” leads to “z3 = z2 minus y”. Line: normal; ends: arrow point.
- “z3 = z2 minus y” leads to “L = z3 squared”. Line: normal; ends: arrow point.
4.2 Forward pass — pick numbers and compute
With , the values are . Write both values and sensitivities beside the nodes; confusing an activation with its gradient is a common implementation error.
4.3 Backward pass — walk right-to-left, multiply local derivatives
Seed . Squaring gives . Subtraction passes -6 to and +6 to the target. Addition passes -6 to and -6 to . Multiplication passes to and to . Thus increasing slightly lowers the loss. For a parameter update, use the old forward values until the whole backward pass finishes.
4.4 Sanity — numeric check
A second, intentionally different example uses the half squared loss: at , , and gradients are 8 and 4. A step of 0.1 gives . The factor of one-half accounts for the differing gradient formula.
import math
def f(w):
return .5 * (2*w - 2)**2
for h in (1e-3, 1e-4, 1e-5):
numeric = (f(3+h) - f(3-h)) / (2*h)
assert math.isclose(numeric, 8., abs_tol=1e-7)
print('central differences agree with 8')The exact retained numerical witness checks that second example. The supplemental program below also checks the original example and the derivative-building exercises. Agreement is evidence at the selected inputs, not a proof over every branch.
5 · Coding a mini-autograd — a complete scalar engine
Let . At , and . Deduplicate nodes in the topological traversal, but never deduplicate their uses. w*w has two contributing operand positions, and u+u has two contributing addends.
class Value:
def __init__(self, data, parents=()):
self.data, self.grad = float(data), 0.0
self.parents = parents
self.pullback = lambda: None
def __add__(self, other):
out = Value(self.data + other.data, (self, other))
def backward():
self.grad += out.grad
other.grad += out.grad
out.pullback = backward
return out
def __mul__(self, other):
out = Value(self.data * other.data, (self, other))
def backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out.pullback = backward
return out
def backward(self):
order, seen = [], set()
def visit(node):
if node not in seen:
seen.add(node)
for parent in node.parents:
visit(parent)
order.append(node)
visit(self)
for node in order:
node.grad = 0.0
self.grad = 1.0
for node in reversed(order):
node.pullback()
w = Value(3)
u = w * w
loss = u + u
loss.backward()
assert loss.data == 18 and w.grad == 12
print('shared-path gradient', w.grad)This exact retained engine deliberately accepts Value operands rather than arbitrary numeric coercion, and resets every reachable gradient for each backward call. That makes replay on an unchanged graph idempotent. PyTorch instead accumulates into leaf .grad fields across backward calls and normally releases saved graph data. Do not use one contract to explain the other. The old example (-1)*y required a reflected multiplication method it did not define; Value(-1)*y works with this engine.
Do not mutate Value.data between the forward pass and any backward replay. Multiplication reads its parents’ data in the pullback; this teaching engine has no mutation/version checks. Rebuild the forward graph after changing a parameter. Resetting gradients makes replay idempotent only while those forward values stay unchanged.
The driver uses reverse topological order, not merely reversing construction timestamps. A closure applies a local vector–Jacobian product. This engine is scalar and recursive, so it is not a large-graph implementation, compiler, device runtime or framework-equivalent replica.
6 · Gradients for vector and matrix ops
6.1 The linear layer — the atom of many networks
For , shapes are , , and . Given an arbitrary upstream cotangent of shape ,
6.2 Where do those formulas come from?
Expand and . The coefficient of is ; the coefficient of is ; and the coefficient of is . This derivation also explains why a broadcast forward sums backward. For more leading axes, sum over all replicated axes and restore the original singleton dimensions.
A batch mean divides once, normally inside . Do not average dW again if already includes the denominator. A shape-correct gradient can still be wrong by a factor of batch size.
6.3 The sanity check I always run
Use the scalar witness . It makes arbitrary upstream gradients testable without building the rest of a network. Check every coordinate of small X, W and b, not only one conveniently chosen weight. Reject a deliberately wrong db=G.mean(0) to ensure the test detects a realistic bug.
import numpy as np
import math
def central(f, x, h=1e-5):
return (f(x+h)-f(x-h))/(2*h)
assert abs(central(lambda x: x**3-2*x, 2.)-10) < 1e-7
assert abs(central(lambda x: 3*x*x+2*x+5, 1.)-8) < 1e-7
assert abs(central(lambda x: (3*x+1)**2, 1.)-24) < 1e-7
assert abs(central(lambda w: (3*w+1-10)**2, 2.)+18) < 1e-7
assert abs(central(lambda b: (6+b-10)**2, 1.)+6) < 1e-7
w = Value(2.); b = Value(1.)
e = w*Value(3.) + b + Value(-10.)
L = e*e; L.backward()
assert (L.data, w.grad, b.grad) == (9., -18., -6.)
L.backward(); assert w.grad == -18. # this engine resets, not accumulates
# Extend without changing the protected Value implementation.
def neg(x): return x*Value(-1.)
def sub(x, y): return x+neg(y)
def power(x, n):
assert isinstance(n, int) and n >= 0
out = Value(1.)
for _ in range(n): out = out*x
return out
def unary(x, fn, derivative):
value = fn(x.data)
out = Value(value, (x,))
slope = derivative(x.data, value) # save forward-time derivative
def back(): x.grad += slope*out.grad
out.pullback = back
return out
def vtanh(x): return unary(x, math.tanh, lambda z, a: 1-a*a)
def vexp(x): return unary(x, math.exp, lambda z, a: a)
def vlog(x):
assert x.data > 0
return unary(x, math.log, lambda z, a: 1/z)
w = Value(1.); b = Value(.2)
a = vtanh(w*Value(.5)+b); a.backward()
assert abs(w.grad-.5*(1-math.tanh(.7)**2)) < 1e-12
assert abs(w.grad-central(lambda w: math.tanh(.5*w+.2), 1.)) < 1e-8
x = Value(3.); power(sub(x, Value(1.)), 3).backward()
assert x.grad == 12.
z = [Value(2.), Value(1.), Value(.1)]
# Fixed shift equals current max; no differentiation through its selection needed.
e = [vexp(sub(t, Value(2.))) for t in z]
loss = sub(vlog(e[0]+e[1]+e[2]), sub(z[0], Value(2.)))
loss.backward()
p = np.exp([0., -1., -1.9]); p /= p.sum(); p[0] -= 1
np.testing.assert_allclose([t.grad for t in z], p, atol=1e-12)
rng = np.random.default_rng(4)
X = rng.normal(size=(4,3)); W = rng.normal(size=(3,2))
b = rng.normal(size=2); G = rng.normal(size=(4,2))
args = [X, W, b]; analytic = [G@W.T, X.T@G, G.sum(0)]
def objective(): return float(((X@W+b)*G).sum())
for arr, expected in zip(args, analytic):
numeric = np.zeros_like(arr)
for idx in np.ndindex(arr.shape):
old = arr[idx]; arr[idx] = old+1e-5; plus = objective()
arr[idx] = old-1e-5; minus = objective(); arr[idx] = old
numeric[idx] = (plus-minus)/2e-5
np.testing.assert_allclose(numeric, expected, atol=1e-8)
assert not np.allclose(G.mean(0), analytic[2])
assert central(lambda x: max(0., x), 0.) == .5 # not our zero subgradient
print('scalar extensions, all matrix gradients and negative controls passed')7 · The full mental model — reverse-mode AD in one paragraph
Evaluate the forward graph, saving values needed by each local backward rule. Seed an output cotangent, then traverse dependencies in reverse topological order. Multiply the accumulated cotangent by each local Jacobian without normally materializing that Jacobian, and add every contribution to the input cotangents. A scalar loss needs one reverse sweep for all parameter gradients. Forward mode propagates an input direction, which is preferable for many outputs and few input directions; neither mode is universally superior.
For ordinary differentiable primitives with inexpensive pullbacks, backward has work of the same asymptotic order as forward, often a small constant multiple. Tensor operation cost matters: counting graph nodes alone treats a scalar addition and a large matrix multiply as equal. Memory depends on what each rule saves, not necessarily every forward intermediate. Checkpointing changes that tradeoff.
7b · Autograd implementations and higher derivatives
7b.1 · PyTorch — dynamic dependency graph
PyTorch records autograd operations during the forward pass and reconstructs the graph on subsequent passes, allowing ordinary executed Python control flow. Saved tensors support backward. model.eval() changes selected module behaviour, not gradient recording; no_grad, inference mode and detach are distinct controls. A detached tensor can share storage with its source.
torch.compile uses graph capture, AOTAutograd and compiler backends; it need not produce one kernel and can encounter graph breaks or recompilations. The old 10–15% overhead and universal 20–40% speedup were unsupported. This lesson tests eager CPU autograd only, not compilation throughput.
7b.2 · JAX — function transformations
JAX's grad transforms a scalar-output function; vmap batches functions and jit compiles supported computations. grad(vmap(loss)) is generally invalid because batching makes the output nonscalar. Use vmap(grad(single_loss)) for per-example gradients, or grad(lambda w: mean(vmap(single_loss)(w, batch))) with appropriate mapped axes for an average loss. Pure-function discipline and tracing constraints are part of the API, not proof that arbitrary stateful Python can be differentiated. No JAX installation is needed for this source-reviewed comparison.
7b.3 · tinygrad and MLX — reading another implementation
tinygrad and Apple's MLX offer other implementations of tensor differentiation. MLX documents NumPy-like APIs, function transformations, lazy evaluation and shared CPU/GPU memory on Apple silicon. These are implementation choices around differentiation, not evidence that either library is a fixed number of lines or readable in a weekend. To study one, trace an add/multiply backward rule, a shared-value test, gradient reset semantics and a device synchronization point. We do not run either framework here.
7b.4 · Higher-order gradients and optimizer distinctions
For , the Hessian contains derivatives of the gradient. Often one needs only , a Hessian-vector product, not a dense parameter-squared matrix. Differentiating computes it. MAML-style objectives differentiate through a parameter update and can require these terms.
Shampoo builds matrix preconditioners from gradient statistics; SOAP uses Adam in a Shampoo-derived eigenbasis. Muon transforms momentum matrices using approximate orthogonalization. These are not all “chain rule twice” or explicit Hessian methods. Sophia includes curvature-estimation variants; a name associated with second-order optimization does not identify the estimator or guarantee a speedup. DL006 derives their distinctions.
7b.5 · Gradient checkpointing — trading recomputation for memory
For a simple chain of L equal-sized layers, saving every k-th boundary retains about L/k checkpoints; recomputing one segment needs about k intermediate activations. The toy peak model is , minimized at . For L=100, k=10 gives about20 activation units rather than100; this ignores parameters, gradients and workspaces. It is not a universal square-root saving or 30% overhead law.
The original (32,4096,4096) activation holds 536,870,912 elements: 1 GiB at two bytes each, not64 GB per activation. A layer can retain several such tensors, so this is not a whole-layer memory estimate. Checkpointed re-execution must preserve the relevant computation, including randomness and state. PyTorch2.9 recommends explicitly choosing use_reentrant=False; API variants differ.
import torch
from torch.utils.checkpoint import checkpoint
theta = torch.tensor([2., -1.], dtype=torch.float64, requires_grad=True)
v = torch.tensor([.3, .7], dtype=torch.float64)
f = (theta**4).sum()
g, = torch.autograd.grad(f, theta, create_graph=True)
hv, = torch.autograd.grad((g*v).sum(), theta)
torch.testing.assert_close(hv, 12*theta.detach()**2*v)
def block(x): return torch.tanh(x@matrix)
matrix = torch.tensor([[1., .3], [-.2, .8]], dtype=torch.float64)
a = torch.tensor([[.5, -.7]], dtype=torch.float64, requires_grad=True)
b = a.detach().clone().requires_grad_()
y1 = block(a); y2 = checkpoint(block, b, use_reentrant=False)
y1.sum().backward(); y2.sum().backward()
torch.testing.assert_close(y1, y2); torch.testing.assert_close(a.grad, b.grad)
assert 32*4096*4096*2 == 1024**3
print('CPU higher-order and checkpoint output/gradient contracts passed')8 · Gradient failure cases and method tradeoffs
- Bias not reduced: assigning
db=Gleaves an extra batch axis; averaging instead of summing can silently rescale a valid shape. Derive the reduction from sharing. - Accumulation versus reuse: intentional microbatch accumulation differs from stale gradients. Zero leaves between independent PyTorch updates; our scalar engine resets the entire reachable graph. Repeated backward on a retained framework graph has additional lifetime rules.
- ReLU at zero: zero is common under padding, initialization and quantization—not “measure zero on floats.” Record the selected derivative convention; a finite-difference disagreement at the kink is expected.
- Logging retains graphs: storing
total_loss = total_loss + losscan retain dependencies. For scalar logging use a detached Python value such asloss.item(); do not detach the loss that should train the model. GPU.item()can also synchronize, a separate cost. - Nonfinite values: differentiating an invalid forward computation can propagate NaNs. Inspect the first failing operation rather than blaming the last gradient.
Finite differences work with deterministic black-box evaluations but cost two evaluations per coordinate for the centred version. Forward differences can reuse a baseline and need one extra per coordinate. Analytic formulas are inspectable and sometimes faster but need maintenance; autograd composes registered local rules, yet custom incorrect rules, nondifferentiable branches and numerical instability remain possible. Pair an analytic or autograd implementation with independent numerical and algebraic witnesses rather than calling any method infallible.
The forward pass carries values; the backward pass carries cotangents. At a forward split, backward contributions add. A residual path contributes an identity term, but does not guarantee well-conditioned gradients. Products of Jacobians can vanish or explode; inspect the actual weights, activation derivatives and normalization, not just the number of layers.
10 · Retention scaffold
- State both chain-rule notations. and describe the same local composition. For at1 they give .
- Re-derive sigmoid's derivative. The reciprocal contributes a minus sign and contributes another; factoring leaves , whose peak is1/4.
- Diagnose two backward bugs. Overwriting shared contributions reports6 instead of12 for at3; failing to sum a broadcast bias leaves
(B,H)instead of(H,). A mean instead of sum is the same shape but wrong magnitude. - Give all linear gradients and shapes. is
(B,D), is(D,H), andG.sum(0)is(H,). A loss denominator belongs exactly once. - When is forward mode attractive? For one input direction and many outputs, a single forward directional sweep can deliver all output sensitivities. Reverse mode needs one seed per output for the full Jacobian; compare dimensions before choosing.
- Derive the checkpoint rule. Differentiate L/k+k to obtain , hence k≈√L; round to feasible segment lengths and measure real memory. Saved RNG state, workspaces and uneven layers break the simple cost model.
Recall — from memory
Explain why graph nodes are visited once but edges can contribute more than once; reconstruct the values9 and gradients-18,-6; and distinguish a scalar backward() seed from a supplied cotangent for a nonscalar output. “Gradient shape equals parameter shape” is a consequence of differentiating a scalar with respect to each coordinate, not a complete test.
Stretch — for one extra hour
The tested extension implements subtraction, nonnegative integer powers, tanh, exp and positive-domain log around the retained engine. Rebuild it without reading. At and w=1, the weight derivative is , about0.31737; the bias derivative is twice that. The three-class log-softmax example produces softmax minus one-hot. Test a reused input to every new binary operation and a second backward call; a test without shared paths misses the engine's central difficulty.
In your own words
Explain how local sensitivities can compute all gradients without constructing a full Jacobian. Include “multiply along chains, add at shared uses.”
Spaced-review pointer
Tomorrow, rebuild the shared-value graph and all-coordinate matrix check. A week later, derive the checkpoint cost model and explain why a compiler optimization is a different claim from derivative correctness.
Next-session teaser
DL005 chooses the loss we differentiate: probability, entropy, cross-entropy and stable logit calculations.
What to bring back tomorrow — sticky note
Use old forward values, accumulate every edge, unbroadcast gradients, and verify at more than one shape and step size.
Primary references and boundary
PyTorch2.9 autograd, checkpoint, JAX differentiation, MLX0.29.0, and Chen et al.2016 support the implementation comparisons. Source review and local CPU fixtures are not JAX/MLX/GPU execution, compiler benchmarks or production-training evidence.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.