Type at least 2 letters. Enter opens the first result, Esc closes.

    Calculus for ML · Lesson 5 · Chain rule, automatic differentiation and backpropagation

    Automatic differentiation: forward mode and reverse mode

    Intermediate 80 min 6 interactive figures 4 videos Colab notebook

    Before this lesson: lesson 04 (the chain rule, VJPs, computational graphs and the two sweeps). Linear algebra lesson 15 gives a short first view of JVPs and VJPs; this lesson goes much deeper. Time: about 80 minutes, plus the notebook.


    Why this matters #

    • Training. loss.backward() in PyTorch and jax.grad in JAX are reverse-mode AD. One call gives the gradient with respect to all parameters, for about 2–3 times the cost of the forward pass.
    • Memory. Activation memory during training is the tape of reverse mode. Activation (gradient) checkpointing, used to train large language models, is the memory–time trade-off of section 3.
    • Forward mode has its own uses: sensitivities of a simulator with few parameters, and Hessian-vector products (forward mode applied to a gradient, lesson 08).
    • Interviews and debugging. "How does autograd work?", "why does training use reverse mode?", "why do I run out of memory?" all have exact answers in terms of the two modes.

    1. Predict first #

    2. The picture #

    Three ways to get a derivative.

    symbolic numerical (finite differences) automatic (AD)
    what it differentiates a formula a black-box function a program, step by step
    result a new formula an approximation exact numbers (up to rounding)
    main problem the formula can grow very large truncation and rounding errors memory (reverse mode)
    cost of a full gradient in Rn\R^n depends on the formula n+1n + 1 or 2n2n evaluations about 2–3 evaluations (reverse mode)

    Symbolic differentiation (as in SymPy or Mathematica) works on formulas. Formulas can grow very large. Take the logistic map lk+1=4 lk(1−lk)l_{k+1} = 4\,l_k(1 - l_k), starting from l0=xl_0 = x. The program for l4l_4 has 12 operations (3 per step). Written as one formula, l4l_4 has 38 operations, because each step copies the previous formula twice; SymPy's derivative of that formula, before simplification, has 264 operations. This growth is called expression swell (Baydin et al. 2018, Table 1).

    Numerical differentiation treats ff as a black box and uses (f(x+ηei)−f(x−ηei))/(2η)\big(f(x + \eta e_i) - f(x - \eta e_i)\big)/(2\eta). It needs two evaluations per input, and the result has truncation and rounding errors (lesson 07). It is the standard tool to check a gradient, not to train with.

    Automatic differentiation does not build a formula. It applies the chain rule of lesson 04 to the primitive operations, while the program runs, at the current numbers. So loops, branches and function calls are no problem: AD differentiates the steps that were actually executed.

    Forward mode: values and tangents move together. Choose an input direction x˙\dot x. Next to every value viv_i, carry its tangent v˙i\dot v_i: the rate at which viv_i changes when the input moves along x˙\dot x. Each primitive updates the pair (value, tangent) with its local derivative. This is the forward sweep of lesson 04, run during the forward computation.

    Interactive figure: loading…

    Press Next step and predict each blue number. Each step computes a value and its tangent together, from left to right. With the seed x˙=e1=(1,0)\dot x = e_1 = (1, 0) the output tangent is ∂f/∂x1=5.5\partial f/\partial x_1 = 5.5. Change the seed to e2e_2 and run again to get ∂f/∂x2=1.716\partial f/\partial x_2 = 1.716: one forward pass gives one directional derivative, so two inputs need two passes. With v=(1,2)v = (1, 2) you get ∇f⊤v\nabla f^\top v in one pass.

    Dual numbers: the tangent as a second part of the number. Write a number with a tangent as a+b εa + b\,\varepsilon, where ε\varepsilon is a symbol with ε2=0\varepsilon^2 = 0 (but ε≠0\varepsilon \ne 0). Multiply two such numbers:

    (a+a′ε)(b+b′ε)=ab+(a′b+ab′) ε+a′b′ ε2=ab+(a′b+ab′) ε.(a + a'\varepsilon)(b + b'\varepsilon) = ab + (a'b + ab')\,\varepsilon + a'b'\,\varepsilon^2 = ab + (a'b + ab')\,\varepsilon .

    The ε\varepsilon part is exactly the product rule. If you evaluate a function at x+1εx + 1\varepsilon, the ε\varepsilon part of the result is f′(x)f'(x). The figure runs small programs on dual numbers.

    Interactive figure: loading…

    Press Next step and predict the blue ε part before it appears. The input is x+1εx + 1\varepsilon. Each line applies one rule (shown on the right) to dual numbers; ε2=0\varepsilon^2 = 0 removes the second-order term. At the end the ε part equals the derivative from the formula, to all printed digits. Try loop (Horner): the program runs a loop three times, and AD still gives the exact derivative.

    Quick check

    What is (3+2ε)2(3 + 2\varepsilon)^2 in dual numbers (ε2=0\varepsilon^2 = 0)?

    Reverse mode: first record, then walk back. Reverse mode needs the adjoints vˉi=∂y/∂vi\bar v_i = \partial y/\partial v_i. The adjoint of a node depends on its children, which come later in the program. So reverse mode cannot run during the forward computation. It works in two phases:

    1. Forward run. Run the program and record each primitive operation on a tape: which variables it used, and the values needed for its local derivatives.
    2. Backward run. Seed yˉ=1\bar y = 1. Walk the tape from the end to the start. For each entry vi=φ(vj,… )v_i = \varphi(v_j, \dots), add vˉi ∂vi/∂vj\bar v_i\,\partial v_i/\partial v_j to the adjoint of each input vjv_j.
    Interactive figure: loading…

    Step through both phases. In the forward run, each step writes one line of the tape (the table): the operation and its value. In the backward run, the tape is read from the bottom up. Each step sends vˉi\bar v_i times the local derivative to the parents. The local derivatives use stored values: ∂v2/∂v0=v−1=2\partial v_2/\partial v_0 = v_{-1} = 2 needs the value of x1x_1 from the forward run. Contributions to x1x_1 and x2x_2 add because both fan out.

    Quick check

    Reverse mode walks back through the program after the forward run. What must the forward run keep for it?

    3. Definitions and main results #

    Let F:Rn→RmF:\R^n\to\R^m be computed by a program of primitive operations, as in lesson 04: inputs v1−n,…,v0v_{1-n},\dots,v_0, intermediate variables v1,v2,…v_1, v_2, \dots in topological order, and outputs y=(y1,…,ym)y = (y_1, \dots, y_m). Write J=DF(x)∈Rm×nJ = DF(x)\in\R^{m\times n}.

    • A Jacobian-vector product (JVP) is JvJv for a vector v∈Rnv\in\R^n (an input direction, also called a tangent). With v=ejv = e_j it is column jj of JJ.
    • A vector-Jacobian product (VJP) is u⊤Ju^\top J (or J⊤uJ^\top u) for a vector u∈Rmu\in\R^m (an output weighting, also called a cotangent). With u=eiu = e_i it is row ii of JJ.

    Forward mode (tangent mode) #

    Algorithm (forward mode). Input: xx and a tangent x˙\dot x.

    1. Set vj−n=xjv_{j-n} = x_j and v˙j−n=x˙j\dot v_{j-n} = \dot x_j for the inputs.
    2. For each operation vi=φi(parents)v_i = \varphi_i(\text{parents}) in program order, compute the value viv_i and the tangent v˙i=∑j∈parents(i)∂φi∂vj v˙j\dot v_i = \sum_{j\in\text{parents}(i)} \frac{\partial \varphi_i}{\partial v_j}\,\dot v_j.
    3. Return yy and y˙\dot y.

    Claim. y˙=Jx˙\dot y = J\dot x, a JVP. Proof: this is the forward sweep of lesson 04, applied to each output yky_k: y˙k=∇yk⊤x˙\dot y_k = \nabla y_k^\top\dot x, which is row kk of JJ times x˙\dot x. ■\blacksquare

    Step 2 needs only the parents of viv_i, which were just computed. So a tangent can be freed as soon as no later operation needs it: forward mode needs no tape.

    Dual numbers #

    A dual number is a+bεa + b\varepsilon with real a,ba, b and the rule ε2=0\varepsilon^2 = 0. The arithmetic follows from this one rule:

    (a+a′ε)±(b+b′ε)=(a±b)+(a′±b′)ε,(a+a′ε)(b+b′ε)=ab+(a′b+ab′)ε,(a + a'\varepsilon) \pm (b + b'\varepsilon) = (a\pm b) + (a'\pm b')\varepsilon,\qquad (a + a'\varepsilon)(b + b'\varepsilon) = ab + (a'b + ab')\varepsilon,
    a+a′εb+b′ε=ab+a′b−ab′b2 ε(b≠0).\frac{a + a'\varepsilon}{b + b'\varepsilon} = \frac{a}{b} + \frac{a'b - ab'}{b^2}\,\varepsilon \quad (b \ne 0).

    (For the quotient, multiply the claimed result by b+b′εb + b'\varepsilon and check that you get a+a′εa + a'\varepsilon.)

    Theorem (dual numbers give derivatives). For every polynomial pp, p(a+bε)=p(a)+p′(a) b εp(a + b\varepsilon) = p(a) + p'(a)\,b\,\varepsilon.

    Proof.

    1. For a power, the binomial theorem gives (a+bε)k=ak+k ak−1b ε+(k2)ak−2b2ε2+⋯(a + b\varepsilon)^k = a^k + k\,a^{k-1}b\,\varepsilon + \binom{k}{2}a^{k-2}b^2\varepsilon^2 + \cdots.
    2. Every term with ε2\varepsilon^2 or a higher power is 0. So (a+bε)k=ak+kak−1 b ε(a + b\varepsilon)^k = a^k + k a^{k-1}\,b\,\varepsilon.
    3. A polynomial is a sum of terms ckxkc_k x^k. Add the results of step 2: p(a+bε)=∑kckak+(∑kk ckak−1)b ε=p(a)+p′(a) b εp(a + b\varepsilon) = \sum_k c_k a^k + \big(\sum_k k\,c_k a^{k-1}\big)b\,\varepsilon = p(a) + p'(a)\,b\,\varepsilon. ■\blacksquare

    For other primitives we define φ(a+bε)=φ(a)+φ′(a) b ε\varphi(a + b\varepsilon) = \varphi(a) + \varphi'(a)\,b\,\varepsilon, for example sin⁡(a+bε)=sin⁡a+(cos⁡a) b ε\sin(a + b\varepsilon) = \sin a + (\cos a)\,b\,\varepsilon. This is consistent with composition: φ(ψ(a+bε))=φ(ψ(a))+φ′(ψ(a)) ψ′(a) b ε\varphi(\psi(a + b\varepsilon)) = \varphi(\psi(a)) + \varphi'(\psi(a))\,\psi'(a)\,b\,\varepsilon, which is the chain rule. So running a whole program on x+x˙εx + \dot x\varepsilon is exactly forward mode, and the ε\varepsilon part of the output is Jx˙J\dot x.

    A dual number is not a small number. There is no step size, so there is no truncation error, unlike finite differences. In code, a dual number is a pair (value, tangent) with overloaded operators (__add__, __mul__, …). This technique is called operator overloading: you run your ordinary code, and the new number type does the calculus (section 5).

    Reverse mode (adjoint mode) #

    Algorithm (reverse mode). Input: xx and a cotangent u∈Rmu\in\R^m.

    1. Forward run. Evaluate the program. For each operation vi=φi(parents)v_i = \varphi_i(\text{parents}), push an entry on the tape: the index ii, the parents, and the values needed to compute ∂φi/∂vj\partial\varphi_i/\partial v_j.
    2. Seed. Set all adjoints to 0, then yˉ=u\bar y = u.
    3. Backward run. Pop the tape entries in reverse order. For entry ii and each parent jj: vˉj+=vˉi ∂φi/∂vj\bar v_j \mathrel{+}= \bar v_i\,\partial\varphi_i/\partial v_j.
    4. Return the input adjoints xˉ=J⊤u\bar x = J^\top u.

    Claim. The result is the VJP J⊤uJ^\top u. Proof: define the scalar s=u⊤ys = u^\top y. Its gradient is ∇s=J⊤u\nabla s = J^\top u (the chain rule for the linear map y↦u⊤yy\mapsto u^\top y). The backward sweep of lesson 04 for the scalar ss starts with yˉ=∂s/∂y=u\bar y = \partial s/\partial y = u and gives ∂s/∂x\partial s/\partial x. The reverse order of the tape is a reverse topological order, so every adjoint is complete before it is used. ■\blacksquare

    The += in step 3 is the fan-out rule: a variable used rr times gets rr contributions. Libraries like micrograd and PyTorch implement this with a Value (or tensor) object that stores its data, its parents and a small backward function; backward() sorts the graph topologically and calls the backward functions in reverse order.

    One product, two orders #

    For a chain F=fL∘⋯∘f1F = f_L\circ\cdots\circ f_1 with Jk=Dfk(xk−1)∈Rnk×nk−1J_k = Df_k(x_{k-1})\in\R^{n_k\times n_{k-1}}, the Jacobian is J=JL⋯J1J = J_L\cdots J_1. The two modes compute it in the two natural orders:

    forward: Jx˙=JL(⋯(J2 (J1x˙))),reverse: u⊤J=(((u⊤JL) JL−1)⋯)J1.\text{forward: } J\dot x = J_L\big(\cdots\big(J_2\,(J_1\dot x)\big)\big), \qquad \text{reverse: } u^\top J = \big(\big((u^\top J_L)\,J_{L-1}\big)\cdots\big)J_1 .

    Every step is a matrix-vector product, never a matrix-matrix product. In fact the JkJ_k are never built: each primitive comes with a JVP rule (v˙↦Jkv˙\dot v \mapsto J_k\dot v) and a VJP rule (vˉ↦Jk⊤vˉ\bar v\mapsto J_k^\top\bar v). For a dense layer both rules are one matrix-vector product, the same work as the layer itself.

    Cost in time #

    Write ops⁡(F)\operatorname{ops}(F) for the number of operations to evaluate FF.

    forward mode reverse mode
    one pass computes Jx˙J\dot x (one column for x˙=ej\dot x = e_j) u⊤Ju^\top J (one row for u=eiu = e_i)
    full Jacobian nn passes mm passes
    time of one pass ≤c⋅ops⁡(F)\le c\cdot\operatorname{ops}(F) ≤c⋅ops⁡(F)\le c\cdot\operatorname{ops}(F)
    extra memory the current tangents only the tape: all needed intermediate values

    Why one pass costs only a constant times FF. Each primitive has a local derivative that costs a small constant times the primitive itself: for v=abv = ab, the tangent a˙ b+a b˙\dot a\,b + a\,\dot b costs two multiplications and one addition; for v=sin⁡av = \sin a, the derivative needs one cos⁡\cos. Summing over all operations gives a constant times ops⁡(F)\operatorname{ops}(F). Griewank and Walther (2008) prove a bound with c<6c < 6; in practice cc is about 2 to 3 (as quoted by Baydin et al. 2018).

    Consequences.

    • The cheap gradient principle. For a scalar function (m=1m = 1), one reverse pass gives the whole gradient for at most c⋅ops⁡(F)c\cdot\operatorname{ops}(F), independent of the number of inputs nn. Forward mode needs nn passes. This is why every neural network is trained with reverse mode.
    • Few inputs, many outputs (n≪mn \ll m): forward mode wins. Example: how a simulated field with 10410^4 values responds to 3 physical parameters needs 3 forward passes and no tape.
    • Square maps (n=mn = m): the pass counts are equal, and forward mode often wins on memory.
    Interactive figure: loading…

    One pass at a time. Press One pass: forward mode fills one column of the Jacobian per pass, reverse mode one row. For the simulator shape R3→R12\R^3\to\R^{12}, forward mode needs 3 passes and reverse mode 12. Switch the shape to loss and count again. (This figure is from linear algebra lesson 15.)

    Quick check

    You need the full Jacobian of a map R3→R1000\R^3\to\R^{1000}. Which plan needs the fewest passes?

    Cost in memory, and checkpointing #

    In reverse mode, the backward run needs the stored values of the forward run. For a network with LL layers, the tape holds the input of every layer (the activations), so memory grows with depth and with batch size. Forward mode keeps only the current values and tangents.

    Checkpointing trades memory for time. Number the layer inputs a0,…,aL−1a_0, \dots, a_{L-1} (layer jj needs aj−1a_{j-1} in the backward run). Choose an interval kk.

    1. Forward run: keep only the checkpoints a0,ak,a2k,…a_0, a_k, a_{2k}, \dots (about L/kL/k of them).
    2. Backward run, segment by segment from the end: recompute the activations of one segment from its checkpoint (at most k−1k - 1 values), run the backward steps of that segment, then free them.

    Count. The peak memory is about L/kL/k checkpoints plus kk values of one segment: M(k)≈L/k+kM(k) \approx L/k + k. The extra work is one recomputation of every non-checkpoint activation: fewer than LL layer evaluations, so at most one extra forward pass. M(k)M(k) is smallest where its derivative −L/k2+1-L/k^2 + 1 is 0, at k=Lk = \sqrt L, with M≈2LM \approx 2\sqrt L instead of LL (Chen et al. 2016).

    Interactive figure: loading…

    Press backward step several times. The forward run kept only the orange checkpoints. When the backward run enters a segment, it recomputes that segment's activations (blue), uses them, and frees them. Watch the count of stored activations. Then move the interval kk: the chart shows the peak memory and the recomputed layers for every kk; the peak memory is lowest near k=Lk = \sqrt L.

    Logarithmic memory: binomial checkpointing

    Checkpointing can be applied recursively: inside a segment, place checkpoints again. Griewank's binomial checkpointing ("revolve", 1992) reverses a chain of LL steps with memory O(log⁡L)O(\log L) for O(Llog⁡L)O(L\log L) time. It is used for long time-stepping simulations (adjoints of ODE and PDE solvers), where storing every step is impossible. Blondel and Roulet (2024, §8.5) describe recursive halving and the dynamic-programming schedule.

    Quick check

    A network has L=100L = 100 layers. You checkpoint every k=10k = 10 layers. Compared with storing all activations, what do you get?

    Full Jacobians and the order of the product #

    To build the whole Jacobian, you can run nn JVPs (columns) or mm VJPs (rows). With dense Jacobians, you can also multiply the matrices JL⋯J1J_L\cdots J_1 directly. Starting from the input side, step kk multiplies JkJ_k by the accumulated nk−1×n0n_{k-1}\times n_0 block; starting from the output side, the accumulated nL×nkn_L\times n_k block by JkJ_k. The numbers of multiplications are

    input side (forward): n0∑k=2Lnknk−1,output side (reverse): nL∑k=1L−1nknk−1.\text{input side (forward): } n_0\sum_{k=2}^{L} n_k n_{k-1}, \qquad \text{output side (reverse): } n_L\sum_{k=1}^{L-1} n_k n_{k-1}.

    The prefactors n0n_0 and nLn_L usually decide: start on the side with the small dimension. When a narrow layer sits in the middle, a mixed order can beat both ends.

    Interactive figure: loading…

    Compare the three bars (log scale). For the loss shape 1000→100→100→11000\to100\to100\to1, the output side needs 1.1⋅1051.1\cdot10^5 multiplications and the input side 1.01⋅1071.01\cdot10^7: about 92 times more. Switch to simulator and the input side wins. Try bottleneck (100→1000→1→1000→100100\to1000\to1\to1000\to100): both ends cost about 10710^7, but the best order (J4J3)(J2J1)(J_4J_3)(J_2J_1) needs only 2.1⋅1052.1\cdot10^5. Move the width sliders and predict which bar is shortest before you look.

    The best order is a dynamic program

    The cheapest bracketing of a matrix chain is found by the classic matrix-chain dynamic program in O(L3)O(L^3) time: the best cost of Jj⋯JiJ_j\cdots J_i is the minimum over the split point ss of the costs of the two parts plus the cost of their product. The figure uses it for the third bar. For general computational graphs (not chains), finding the cheapest way to accumulate the Jacobian is NP-complete (Naumann 2008), so tools use heuristics. In ML this rarely matters: training needs one VJP, and curvature methods need JVPs and VJPs of the gradient, not full Jacobians.

    AD in practice #

    The same two modes appear in every framework, under different names.

    what you write what it does
    x.requires_grad_(True) (PyTorch) record operations on x on the tape
    y.backward() (PyTorch) reverse sweep with seed yˉ=1\bar y = 1; adds the result into x.grad
    y.backward(u) (PyTorch) reverse sweep with seed uu for a non-scalar yy: the VJP J⊤uJ^\top u
    retain_graph=True (PyTorch) keep the tape after the backward run (it is freed by default, to save memory)
    with torch.no_grad(): do not record a tape (inference; saves memory)
    torch.func.jvp, jax.jvp forward mode: one JVP
    torch.func.vjp, jax.vjp reverse mode: returns a function u↦J⊤uu\mapsto J^\top u
    jax.grad reverse mode for a scalar function
    jax.jacfwd, jax.jacrev full Jacobian from nn JVPs or mm VJPs (batched with vmap)
    torch.utils.checkpoint, jax.checkpoint checkpointing of a block

    Note that backward() adds into .grad. This is the fan-out rule again, applied across calls: that is why a training loop calls optimizer.zero_grad() before each backward run.

    Answers to section 1. (1) Forward mode needs 10610^6 passes, one per input direction. Reverse mode needs one pass, because the output is a scalar (m=1m = 1); it costs a small constant (about 2–3) times one evaluation of the loss. (2) ∂ℓ/∂W=zˉ a⊤\partial\ell/\partial W = \bar z\,a^\top has the shape of WW, 512×256512\times256, and rank at most 1, because it is an outer product (lesson 04). It has rank exactly 1 when zˉ≠0\bar z\ne 0 and a≠0a\ne 0.

    4. Worked example #

    (a) Both modes on f(x1,x2)=ln⁡x1+x1x2−sin⁡x2f(x_1, x_2) = \ln x_1 + x_1x_2 - \sin x_2 at (2,5)(2, 5), written as the traces an AD tool produces (Baydin et al. 2018, Tables 2 and 3).

    Forward mode, seed x˙=e1=(1,0)\dot x = e_1 = (1, 0). Each line computes the value and the tangent:

    value tangent
    v−1=x1=2v_{-1} = x_1 = 2 v˙−1=1\dot v_{-1} = 1
    v0=x2=5v_0 = x_2 = 5 v˙0=0\dot v_0 = 0
    v1=ln⁡v−1=0.693v_1 = \ln v_{-1} = 0.693 v˙1=v˙−1/v−1=0.5\dot v_1 = \dot v_{-1}/v_{-1} = 0.5
    v2=v−1v0=10v_2 = v_{-1}v_0 = 10 v˙2=v˙−1v0+v−1v˙0=5\dot v_2 = \dot v_{-1}v_0 + v_{-1}\dot v_0 = 5
    v3=sin⁡v0=−0.959v_3 = \sin v_0 = -0.959 v˙3=cos⁡(v0) v˙0=0\dot v_3 = \cos(v_0)\,\dot v_0 = 0
    v4=v1+v2=10.693v_4 = v_1 + v_2 = 10.693 v˙4=v˙1+v˙2=5.5\dot v_4 = \dot v_1 + \dot v_2 = 5.5
    v5=v4−v3=11.652v_5 = v_4 - v_3 = 11.652 v˙5=v˙4−v˙3=5.5\dot v_5 = \dot v_4 - \dot v_3 = 5.5

    Result: ∂f/∂x1=5.5\partial f/\partial x_1 = 5.5. A second pass with x˙=e2\dot x = e_2 gives ∂f/∂x2=1.716\partial f/\partial x_2 = 1.716.

    Reverse mode. The forward run writes the left column of the table above (values only) to the tape. Then the backward run reads the tape from the bottom:

    tape entry (read upward) adjoint updates
    v5=v4−v3v_5 = v_4 - v_3 vˉ5=1\bar v_5 = 1;  vˉ4+=1\ \bar v_4 \mathrel{+}= 1;  vˉ3+=−1\ \bar v_3 \mathrel{+}= -1
    v4=v1+v2v_4 = v_1 + v_2 vˉ1+=1\bar v_1 \mathrel{+}= 1;  vˉ2+=1\ \bar v_2 \mathrel{+}= 1
    v3=sin⁡v0v_3 = \sin v_0 vˉ0+=vˉ3cos⁡v0=−0.284\bar v_0 \mathrel{+}= \bar v_3\cos v_0 = -0.284
    v2=v−1v0v_2 = v_{-1}v_0 vˉ−1+=vˉ2 v0=5\bar v_{-1} \mathrel{+}= \bar v_2\,v_0 = 5;  vˉ0+=vˉ2 v−1=2⇒vˉ0=1.716\ \bar v_0 \mathrel{+}= \bar v_2\,v_{-1} = 2 \Rightarrow \bar v_0 = 1.716
    v1=ln⁡v−1v_1 = \ln v_{-1} vˉ−1+=vˉ1/v−1=0.5⇒vˉ−1=5.5\bar v_{-1} \mathrel{+}= \bar v_1/v_{-1} = 0.5 \Rightarrow \bar v_{-1} = 5.5

    Result: both partial derivatives, (5.5,1.716)(5.5, 1.716), from one backward run. The stored values v0=5v_0 = 5, v−1=2v_{-1} = 2 and cos⁡v0\cos v_0 were needed: this is what the tape is for.

    (b) A full Jacobian by JVPs and by VJPs. Let F=f2∘f1:R2→R3F = f_2\circ f_1:\R^2\to\R^3 with

    f1(x)=(x1x2x2−x1),f2(u)=(u1+u2u1u2u22),x=(12).f_1(x) = \begin{pmatrix} x_1x_2 \\ x_2 - x_1\end{pmatrix}, \qquad f_2(u) = \begin{pmatrix} u_1 + u_2 \\ u_1u_2 \\ u_2^2\end{pmatrix}, \qquad x = \begin{pmatrix}1\\2\end{pmatrix}.
    1. Forward run: u=f1(1,2)=(2,1)u = f_1(1, 2) = (2, 1) and F(x)=(3,2,1)F(x) = (3, 2, 1).
    2. Local Jacobians: J1=(x2x1−11)=(21−11)J_1 = \begin{pmatrix} x_2 & x_1 \\ -1 & 1\end{pmatrix} = \begin{pmatrix} 2 & 1 \\ -1 & 1\end{pmatrix} and J2=(11u2u102u2)=(111202)J_2 = \begin{pmatrix} 1 & 1 \\ u_2 & u_1 \\ 0 & 2u_2\end{pmatrix} = \begin{pmatrix} 1 & 1 \\ 1 & 2 \\ 0 & 2\end{pmatrix}.
    3. Two JVPs (forward mode, one per input):
      • e1e_1: J1e1=(2,−1)J_1e_1 = (2, -1), then J2(2,−1)=(1, 2−2, −2)=(1,0,−2)J_2(2, -1) = (1,\ 2 - 2,\ -2) = (1, 0, -2): column 1.
      • e2e_2: J1e2=(1,1)J_1e_2 = (1, 1), then J2(1,1)=(2,3,2)J_2(1, 1) = (2, 3, 2): column 2.
    4. Three VJPs (reverse mode, one per output):
      • e1e_1: J2⊤e1=(1,1)J_2^\top e_1 = (1, 1), then J1⊤(1,1)=(2−1, 1+1)=(1,2)J_1^\top(1, 1) = (2 - 1,\ 1 + 1) = (1, 2): row 1.
      • e2e_2: J2⊤e2=(1,2)J_2^\top e_2 = (1, 2), then J1⊤(1,2)=(2−2, 1+2)=(0,3)J_1^\top(1, 2) = (2 - 2,\ 1 + 2) = (0, 3): row 2.
      • e3e_3: J2⊤e3=(0,2)J_2^\top e_3 = (0, 2), then J1⊤(0,2)=(−2,2)J_1^\top(0, 2) = (-2, 2): row 3.
    5. Both give J=(1203−22)J = \begin{pmatrix} 1 & 2 \\ 0 & 3 \\ -2 & 2\end{pmatrix}. Here n=2<m=3n = 2 < m = 3, so forward mode needs fewer passes.

    Check one entry directly: F2(x)=x1x2(x2−x1)F_2(x) = x_1x_2(x_2 - x_1), so ∂F2/∂x1=x2(x2−x1)−x1x2=2−2=0\partial F_2/\partial x_1 = x_2(x_2 - x_1) - x_1x_2 = 2 - 2 = 0. ✓

    (c) Dual numbers for f(x)=x2sin⁡xf(x) = x^2\sin x at x=1.5x = 1.5. Seed x=1.5+1εx = 1.5 + 1\varepsilon.

    1. v1=x⋅x=(1.5+ε)(1.5+ε)=2.25+3εv_1 = x\cdot x = (1.5 + \varepsilon)(1.5 + \varepsilon) = 2.25 + 3\varepsilon.
    2. v2=sin⁡(1.5+ε)=sin⁡1.5+(cos⁡1.5)ε=0.9975+0.0707εv_2 = \sin(1.5 + \varepsilon) = \sin 1.5 + (\cos 1.5)\varepsilon = 0.9975 + 0.0707\varepsilon.
    3. v3=v1v2=2.25⋅0.9975+(3⋅0.9975+2.25⋅0.0707)ε=2.2444+3.1516εv_3 = v_1v_2 = 2.25\cdot0.9975 + (3\cdot0.9975 + 2.25\cdot0.0707)\varepsilon = 2.2444 + 3.1516\varepsilon.

    Check: f′(x)=2xsin⁡x+x2cos⁡x=3⋅0.9975+2.25⋅0.0707=3.1516f'(x) = 2x\sin x + x^2\cos x = 3\cdot0.9975 + 2.25\cdot0.0707 = 3.1516. ✓

    5. In code #

    A complete forward-mode engine (dual numbers) and a complete reverse-mode engine (a Value class with a tape), in plain Python. Both reproduce the numbers of section 4. The notebook builds them step by step.

    import math
    
    class Dual:
        """a + b·ε with ε² = 0. The ε part (b) carries the derivative."""
        def __init__(self, a, b=0.0):
            self.a, self.b = a, b
        def __add__(self, o):
            o = o if isinstance(o, Dual) else Dual(o)
            return Dual(self.a + o.a, self.b + o.b)
        __radd__ = __add__
        def __sub__(self, o):
            o = o if isinstance(o, Dual) else Dual(o)
            return Dual(self.a - o.a, self.b - o.b)
        def __mul__(self, o):
            o = o if isinstance(o, Dual) else Dual(o)
            return Dual(self.a * o.a, self.b * o.a + self.a * o.b)   # ε² term dropped
        __rmul__ = __mul__
    
    def sin(d): return Dual(math.sin(d.a), math.cos(d.a) * d.b)
    def log(d): return Dual(math.log(d.a), d.b / d.a)
    
    def f(x1, x2):                       # ordinary code; works for Duals
        return log(x1) + x1 * x2 - sin(x2)
    
    y = f(Dual(2.0, 1.0), Dual(5.0, 0.0))     # seed: tangent e1
    print(round(y.a, 3), round(y.b, 3))       # 11.652 5.5
    y = f(Dual(2.0, 0.0), Dual(5.0, 1.0))     # seed: tangent e2
    print(round(y.b, 3))                      # 1.716
    
    class Value:
        """A scalar that records how it was made (the tape) and can run the reverse sweep."""
        def __init__(self, data, parents=(), local=()):
            self.data, self.grad = data, 0.0
            self.parents, self.local = parents, local     # local[i] = d self / d parents[i]
        def __add__(self, o): return Value(self.data + o.data, (self, o), (1.0, 1.0))
        def __sub__(self, o): return Value(self.data - o.data, (self, o), (1.0, -1.0))
        def __mul__(self, o): return Value(self.data * o.data, (self, o), (o.data, self.data))
        def backward(self):
            order, seen = [], set()
            def visit(v):                                   # topological order by depth-first search
                if id(v) not in seen:
                    seen.add(id(v))
                    for p in v.parents: visit(p)
                    order.append(v)
            visit(self)
            self.grad = 1.0                                 # seed: d y / d y = 1
            for v in reversed(order):                       # reverse topological order
                for p, d in zip(v.parents, v.local):
                    p.grad += v.grad * d                    # add: fan-out sums
    
    def vlog(v): return Value(math.log(v.data), (v,), (1.0 / v.data,))
    def vsin(v): return Value(math.sin(v.data), (v,), (math.cos(v.data),))
    
    x1, x2 = Value(2.0), Value(5.0)
    y = vlog(x1) + x1 * x2 - vsin(x2)
    y.backward()
    print(round(y.data, 3), x1.grad, round(x2.grad, 3))    # 11.652 5.5 1.716
    

    The Value objects form the tape: each one keeps its parents and its local derivatives, which use the values of the forward run (o.data, self.data, cos(v.data)).

    The same computations in PyTorch, including the full Jacobian of example (b) by 2 JVPs and by 3 VJPs:

    import torch
    from torch.func import jvp, vjp
    
    x = torch.tensor([2.0, 5.0], dtype=torch.float64, requires_grad=True)
    f = lambda x: torch.log(x[0]) + x[0] * x[1] - torch.sin(x[1])
    y = f(x)                     # forward run: PyTorch records the graph (the tape)
    y.backward()                 # reverse sweep with seed 1
    print(x.grad)                # tensor([5.5000, 1.7163], dtype=torch.float64)
    
    v = torch.tensor([1.0, 2.0], dtype=torch.float64)
    print(jvp(f, (x.detach(),), (v,))[1])      # forward mode, J v: tensor(8.9327, dtype=torch.float64)
    
    F = lambda x: torch.stack([x[0] * x[1] + x[1] - x[0], x[0] * x[1] * (x[1] - x[0]), (x[1] - x[0]) ** 2])
    x0 = torch.tensor([1.0, 2.0], dtype=torch.float64)
    cols = [jvp(F, (x0,), (e,))[1] for e in torch.eye(2, dtype=torch.float64)]
    _, pull = vjp(F, x0)
    rows = [pull(e)[0] for e in torch.eye(3, dtype=torch.float64)]
    print(torch.stack(cols, 1).tolist())   # [[1.0, 2.0], [0.0, 3.0], [-2.0, 2.0]]
    print(torch.stack(rows, 0).tolist())   # [[1.0, 2.0], [0.0, 3.0], [-2.0, 2.0]]
    

    In JAX the same calls are jax.grad(f)(x), jax.jvp(f, (x,), (v,)) and jax.vjp(f, x); with jax.config.update("jax_enable_x64", True) they print the same numbers.

    6. Where it shows up #

    • Training neural networks. Every optimizer step is one reverse-mode pass: the cheap gradient principle makes billions of parameters affordable. Lesson 06 writes the backward pass of an MLP by hand; deep learning lesson 03 covers backpropagation in networks.
    • Activation memory. The tape is the main memory cost of training large models. Activation checkpointing (torch.utils.checkpoint, jax.checkpoint), recomputation and reversible layers reduce it; see the ML systems track for memory budgets of real models.
    • Second derivatives. A Hessian-vector product is forward mode applied to the reverse-mode gradient ("forward-over-reverse") and costs a few gradients (lesson 08). Gradients through solvers and fixed points use implicit differentiation instead of a long tape (lesson 09).
    • Probabilistic programming. Hamiltonian Monte Carlo (Stan, NumPyro, PyMC) needs the gradient of a log density at every step; it gets it from reverse-mode AD.
    • ML for science. Differentiable simulators (fluid, climate, molecular dynamics) use reverse mode through the solver, which is the adjoint method of computational science; with many time steps, memory forces checkpointing. Forward mode gives the sensitivities of a simulation to a few physical parameters. See the ML for science track.

    7. Common mistakes #

    • "AD is symbolic differentiation" or "AD is finite differences". It is neither: it applies the chain rule to the executed operations at the current numbers. It builds no formula and uses no step size.
    • Using forward mode for a loss with many parameters. It needs one pass per parameter. Use reverse mode for Rn→R\R^n\to\R and forward mode when the number of inputs is small.
    • Forgetting that .grad accumulates. backward() adds into .grad. Without optimizer.zero_grad(), gradients of several steps are summed.
    • Keeping the tape alive by accident. total_loss += loss keeps every graph in memory; use total_loss += loss.item() (a plain number) when you only want to log the value.
    • Calling backward() on a vector output without a seed. For non-scalar yy, PyTorch needs y.backward(u): it computes the VJP J⊤uJ^\top u, not the full Jacobian.
    • Trusting AD at a kink or across a branch. AD differentiates the branch that ran. The code x if x != 0 else 0.0 equals xx everywhere, but AD gives derivative 0 at x=0x = 0. For ReLU at 0, frameworks return 0 by convention.

    8. Check yourself #

    1. Compute (2+ε)3(2 + \varepsilon)^3 with dual numbers, and say which derivative you get.
      Answer(2+ε)3=8+3⋅4 ε=8+12ε(2 + \varepsilon)^3 = 8 + 3\cdot 4\,\varepsilon = 8 + 12\varepsilon (all terms with ε2\varepsilon^2 vanish). The ε\varepsilon part is ddxx3=3x2=12\frac{d}{dx}x^3 = 3x^2 = 12 at x=2x = 2.
    2. A program computes F:R50→R50F:\R^{50}\to\R^{50}. You need the full Jacobian. How many passes in each mode, and which do you choose?
      Answer50 JVPs or 50 VJPs: the same number of passes. Choose forward mode: it needs no tape, so its memory does not grow with the length of the program.
    3. Why can reverse mode not compute vˉj\bar v_j at the moment vjv_j is computed in the forward run?
      Answervˉj=∑childrenvˉi ∂vi/∂vj\bar v_j = \sum_{\text{children}} \bar v_i\,\partial v_i/\partial v_j needs the adjoints of the children, which are computed later in the program. So reverse mode must finish the forward run first and keep (on the tape) the values that the local derivatives need.
    4. A chain has widths (n0,n1,n2,n3)=(3,100,100,104)(n_0, n_1, n_2, n_3) = (3, 100, 100, 10^4). Count the multiplications to form the full Jacobian from the input side and from the output side.
      AnswerInput side: 3 (100⋅100+104⋅100)=3.03⋅1063\,(100\cdot100 + 10^4\cdot100) = 3.03\cdot10^6. Output side: 104 (100⋅100+100⋅3)=1.03⋅10810^4\,(100\cdot100 + 100\cdot3) = 1.03\cdot10^8. The input side (forward mode) is about 34 times cheaper.
    5. A network has L=64L = 64 layers. With checkpointing, which interval kk minimizes peak memory, and about how many activations are stored at the peak?
      Answerk=64=8k = \sqrt{64} = 8. The peak is about L/k+k=8+8=16L/k + k = 8 + 8 = 16 activations (exactly 15 with the count in the figure: 7 earlier checkpoints plus a segment of 8), instead of 64. The extra work is 56 recomputed layers, less than one forward pass.
    6. In the Value class, what goes wrong if p.grad += v.grad * d is replaced by p.grad = v.grad * d?
      AnswerAt a fan-out the second contribution overwrites the first. For the example, x1x_1 would get only 0.50.5 (the path through ln⁡x1\ln x_1, processed last) instead of 5.55.5, and x2x_2 only 22 instead of 1.7161.716.
    Recall cardsAnswer in your head first, then reveal. Add the cards to your review deck to see them again just before you would forget.
    1. What does one forward-mode pass compute?

    2. What does one reverse-mode pass compute?

    3. How many passes give the full Jacobian of F:Rn→RmF:\R^n\to\R^m in each mode?

    4. Compute (3+2ε)2(3 + 2\varepsilon)^2 with ε2=0\varepsilon^2 = 0.

    5. State the rule that makes dual numbers compute derivatives.

    6. What does the tape of reverse mode store, and why?

    7. What is the cheap gradient principle?

    8. When does forward mode beat reverse mode?

    9. What does checkpointing trade, and what interval is best for LL layers?

    10. Why does reverse mode use vˉj+=\bar v_j \mathrel{+}= and not ==?

    Open the review →

    9. Interview angle #

    • "How does autograd work?" — The forward run records a graph (tape) of primitive operations and the values they need. backward() seeds yˉ=1\bar y = 1 and visits the nodes in reverse topological order, applying each operation's VJP rule and adding into the adjoints of its inputs.
    • "Why do we train with reverse mode?" — The loss is a scalar: one VJP gives the full gradient for about 2–3 forward passes. Forward mode needs one pass per parameter. The price is activation memory.
    • "When would you use forward mode?" — Few inputs and many outputs; tight memory; JVPs inside Hessian-vector products (forward-over-reverse).
    • "How do you reduce activation memory?" — Checkpointing (store every kk-th activation, recompute; k≈Lk\approx\sqrt L), smaller batches or micro-batching, mixed precision, reversible layers.
    • "Implement a tiny autograd." — A Value class with data, grad, parents and local derivatives; a topological sort; a reverse loop with +=. Then test it against finite differences.

    10. Practice #

    The notebook for this lesson runs in Colab. It has five exercises, each with a check cell and a folded solution:

    1. A Dual class with +, *, sin, exp, log; the derivative of a polynomial program with a loop.
    2. A Value class (tape, topological sort, backward()) with +, *, tanh, exp, log; the gradient of ln⁡x1+x1x2−sin⁡x2\ln x_1 + x_1x_2 - \sin x_2.
    3. jacobian_fwd and jacobian_rev: full Jacobians from the two engines, compared on a map R2→R3\R^2\to\R^3.
    4. chain_flops(dims): the dense costs from the input side and the output side; find the cheaper end.
    5. A checkpointing simulation: count stored activations and recomputations for every interval kk.

    11. Watch and read #

    Symbolic vs numerical vs automatic differentiation, then forward and reverse mode on a small graph, as in section 2.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    Dual numbers with operator overloading, the same idea as the Dual class of section 5.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    Forward and reverse mode on general graphs and why the order of the Jacobian product matters.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    Builds a Value class with a backward function and a topological sort, step by step; the first hour covers the reverse-mode engine of section 5.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play