Skip to main content
© 2026 ePowerAI. All rights reserved.
CoursesContact
eAI
CoursesContact
Week 11: Automatic Differentiation
Adv. Optimization
01Week 1: Optimality Conditions Revisited
02Week 2: Lagrangian Duality in Depth
03Week 3: KKT and Sensitivity
04Week 4: LP, QP, and Conic Form
05Week 5: Semidefinite Programming
06Week 6: Interior-Point Methods
07Week 7: Proximal Methods and ADMM
08Week 8: Newton and Quasi-Newton
09Week 9: Variance Reduction
10Week 10: Large-Batch and Distributed Training
11Week 11: Automatic Differentiation
12Week 12: Sharpness and SAM
13Week 13: Landscapes and Implicit Bias
14Week 14: Capstone — Structure Meets Scale
Week 11· Adv. Optimization· Geometry & training science22 min read

Week 11: Automatic Differentiation

✦Learning Outcomes
  • Distinguish numeric, symbolic, and automatic differentiation
  • Run forward-mode AD using dual numbers on a simple expression
  • Compute a reverse-mode VJP on a tiny computational graph by hand
  • Explain why reverse mode dominates deep learning (one scalar loss, many parameters)
◆Prerequisites

Background: Intermediate Optim Week 1 (chain rule for vector functions, Jacobians). Week 8 (Newton — uses second derivatives; AD can compute those too). Basic graph traversal concepts.

Later weeks: AD is the computational engine behind every gradient-based training recipe. Weeks 12–13 discuss what those gradients reveal about the loss landscape and what optimisers do with them.

Why AD Exists#

Every optimiser in this course needs gradients. How do we get them?

  • Numerical differentiation: f′(x)≈(f(x+h)−f(x))/hf'(x) \approx (f(x+h) - f(x))/hf′(x)≈(f(x+h)−f(x))/h. Simple but O(d)O(d)O(d) evaluations per gradient and suffers from truncation vs roundoff trade-off. Unacceptable for d=107d = 10^7d=107 parameters.
  • Symbolic differentiation: Apply calculus rules to the closed-form expression of fff. 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 (+,×,sin⁡,exp⁡+, \times, \sin, \exp+,×,sin,exp, 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 vvv with a dual number v˙\dot{v}v˙ (its derivative with respect to a chosen input direction). For each elementary operation w=ϕ(u1,…,uk)w = \phi(u_1, \ldots, u_k)w=ϕ(u1​,…,uk​), compute:

w˙=∑j=1k∂ϕ∂uju˙j\dot{w} = \sum_{j=1}^k \frac{\partial \phi}{\partial u_j} \dot{u}_jw˙=∑j=1k​∂uj​∂ϕ​u˙j​

Initialise x˙=1\dot{x} = 1x˙=1 for the input of interest and y˙=0\dot{y} = 0y˙​=0 for all others.

Worked example — Dual numbers for sin⁡(x2)\sin(x^2)sin(x2) at x=2x = 2x=2:

The elementary operations are v1=x2v_1 = x^2v1​=x2, v2=sin⁡(v1)v_2 = \sin(v_1)v2​=sin(v1​). With x˙=1\dot{x} = 1x˙=1:

  • v1=22=4v_1 = 2^2 = 4v1​=22=4, v˙1=2x⋅x˙=2⋅2⋅1=4\dot{v}_1 = 2x \cdot \dot{x} = 2 \cdot 2 \cdot 1 = 4v˙1​=2x⋅x˙=2⋅2⋅1=4
  • v2=sin⁡(4)≈−0.7568v_2 = \sin(4) \approx -0.7568v2​=sin(4)≈−0.7568, v˙2=cos⁡(v1)⋅v˙1=cos⁡(4)⋅4≈−2.6146\dot{v}_2 = \cos(v_1) \cdot \dot{v}_1 = \cos(4) \cdot 4 \approx -2.6146v˙2​=cos(v1​)⋅v˙1​=cos(4)⋅4≈−2.6146

Analytic check: ddxsin⁡(x2)=2xcos⁡(x2)\frac{d}{dx}\sin(x^2) = 2x \cos(x^2)dxd​sin(x2)=2xcos(x2), at x=2x=2x=2: 4cos⁡(4)≈−2.61464 \cos(4) \approx -2.61464cos(4)≈−2.6146. Matches.

Cost of forward mode. For f:Rd→Rpf: \mathbb{R}^d \to \mathbb{R}^pf:Rd→Rp, computing the full Jacobian requires ddd forward passes (one per input dimension). Forward mode is efficient when d≪pd \ll pd≪p (few inputs, many outputs).

Reverse Mode#

Reverse-mode AD (a.k.a. backpropagation) propagates derivatives from outputs back to inputs. For a scalar output f(x)f(x)f(x), this computes the full gradient ∇f(x)\nabla f(x)∇f(x) in a single backward pass.

The algorithm works in two phases:

  1. Forward pass: Evaluate the function, recording the computational graph (the "tape") — store every intermediate value and the operations that produced them.
  2. Backward pass: Traverse the graph in reverse topological order. For each operation w=ϕ(u1,…,uk)w = \phi(u_1, \ldots, u_k)w=ϕ(u1​,…,uk​), having already computed wˉ=∂f/∂w\bar{w} = \partial f / \partial wwˉ=∂f/∂w, compute the adjoint of each input:

uˉj=wˉ⋅∂ϕ∂uj\bar{u}_j = \bar{w} \cdot \frac{\partial \phi}{\partial u_j}uˉj​=wˉ⋅∂uj​∂ϕ​

accumulating contributions from all uses of uju_juj​. At the end, xˉi=∂f/∂xi\bar{x}_i = \partial f / \partial x_ixˉi​=∂f/∂xi​ for all iii.

The backward pass computes a vector-Jacobian product (VJP): given wˉ\bar{w}wˉ, compute uˉ=Jϕ(u)Twˉ\bar{u} = J_\phi(u)^T \bar{w}uˉ=Jϕ​(u)Twˉ, where JϕJ_\phiJϕ​ is the Jacobian of ϕ\phiϕ. The full gradient is the VJP of the scalar 111 with respect to all parameters.

Worked Micro-Graph#

Consider f(a,b,c)=(a⋅b+c)2f(a, b, c) = (a \cdot b + c)^2f(a,b,c)=(a⋅b+c)2 with inputs a=1,b=2,c=0.5a = 1, b = 2, c = 0.5a=1,b=2,c=0.5.

Forward pass (building the graph):

  • v1=a×b=1×2=2v_1 = a \times b = 1 \times 2 = 2v1​=a×b=1×2=2
  • v2=v1+c=2+0.5=2.5v_2 = v_1 + c = 2 + 0.5 = 2.5v2​=v1​+c=2+0.5=2.5
  • v3=v22=2.52=6.25v_3 = v_2^2 = 2.5^2 = 6.25v3​=v22​=2.52=6.25 (output f=v3f = v_3f=v3​)

Backward pass:

  • vˉ3=∂f/∂v3=1\bar{v}_3 = \partial f / \partial v_3 = 1vˉ3​=∂f/∂v3​=1 (seed)
  • Operation v3=v22v_3 = v_2^2v3​=v22​: ∂v3/∂v2=2v2=5\partial v_3 / \partial v_2 = 2v_2 = 5∂v3​/∂v2​=2v2​=5, so vˉ2=1⋅5=5\bar{v}_2 = 1 \cdot 5 = 5vˉ2​=1⋅5=5
  • Operation v2=v1+cv_2 = v_1 + cv2​=v1​+c:
    • ∂v2/∂v1=1\partial v_2 / \partial v_1 = 1∂v2​/∂v1​=1, so vˉ1=vˉ2⋅1=5\bar{v}_1 = \bar{v}_2 \cdot 1 = 5vˉ1​=vˉ2​⋅1=5
    • ∂v2/∂c=1\partial v_2 / \partial c = 1∂v2​/∂c=1, so cˉ=vˉ2⋅1=5\bar{c} = \bar{v}_2 \cdot 1 = 5cˉ=vˉ2​⋅1=5
  • Operation v1=a×bv_1 = a \times bv1​=a×b:
    • ∂v1/∂a=b=2\partial v_1 / \partial a = b = 2∂v1​/∂a=b=2, so aˉ=vˉ1⋅2=10\bar{a} = \bar{v}_1 \cdot 2 = 10aˉ=vˉ1​⋅2=10
    • ∂v1/∂b=a=1\partial v_1 / \partial b = a = 1∂v1​/∂b=a=1, so bˉ=vˉ1⋅1=5\bar{b} = \bar{v}_1 \cdot 1 = 5bˉ=vˉ1​⋅1=5

Analytic check: ∂f/∂a=2(ab+c)⋅b=2(2.5)⋅2=10\partial f / \partial a = 2(ab + c) \cdot b = 2(2.5) \cdot 2 = 10∂f/∂a=2(ab+c)⋅b=2(2.5)⋅2=10 ✓. ∂f/∂b=2(ab+c)⋅a=5\partial f / \partial b = 2(ab + c) \cdot a = 5∂f/∂b=2(ab+c)⋅a=5 ✓. ∂f/∂c=2(ab+c)=5\partial f / \partial c = 2(ab + c) = 5∂f/∂c=2(ab+c)=5 ✓.

Complexity#

Reverse mode for scalar-output functions (the case for most ML losses):

cost(∇f)≤c⋅cost(f)\text{cost}(\nabla f) \leq c \cdot \text{cost}(f)cost(∇f)≤c⋅cost(f)

where ccc is a small constant (typically c≈3c \approx 3c≈3–555). The gradient of a scalar function of ddd variables costs roughly the same as evaluating the function itself — independent of ddd. This is the cheap gradient principle, and it is why deep learning with millions of parameters is computationally feasible.

The trade-off:

  • If ddd (inputs) ≫\gg≫ ppp (outputs), use reverse mode. One backward pass computes all ∂fj/∂xi\partial f_j / \partial x_i∂fj​/∂xi​ for a scalar fff, vs ddd forward passes.
  • If p≫dp \gg dp≫d, use forward mode. Each forward pass computes one column of the Jacobian.
  • Deep learning: ddd (parameters) is enormous, p=1p = 1p=1 (scalar loss). Reverse mode wins by a factor of ddd.

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.

Exercise · Multiple choice

In reverse-mode AD on the graph $w = u^2$, if the adjoint $\bar{w} = 3$, what is $\bar{u}$?

$3$
$6u$
$3u$
$6$
Question 1 of 3

When is forward-mode AD preferred over reverse mode?

When computing the gradient of a scalar loss with many parameters
When the function has few inputs and many outputs ($d ll p$)
When the function is not differentiable
Never — reverse mode is always better

Browser lab#

Implement forward-mode AD (dual numbers) for sin⁡(x2)\sin(x^2)sin(x2) and compare with analytic derivative. Then implement manual reverse-mode AD on the micro-graph f=(a⋅b+c)2f = (a \cdot b + c)^2f=(a⋅b+c)2.

python · runs in browser
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})")
← Previous
Week 10: Large-Batch and Distributed Training
Next →
Week 12: Sharpness and SAM
On this page
  • Why AD Exists
  • Forward Mode
  • Reverse Mode
  • Worked Micro-Graph
  • Complexity
  • Browser lab