Mikhail Mikhasenko

Mikhail Mikhasenko

Why automatic differentiation?

From early neural networks to the need for reliable derivatives

Mark I Perceptron

Mark I Perceptron

Expected at ~1960

Expected at ~1960

What held the progress?

What held the progress?

Ways to compute derivatives

Problem formulation, symbolic differentiation, and finite differences

Diff: Problem formulation

Diff: Problem formulation

Several ways to get derivative

Several ways to get derivative

Analytic Diff

Analytic Diff

Finite Diff

Finite Diff

Forward mode

Propagate values and derivative components together

Forward Diff

Forward Diff

Example to work out

Example to work out

Reverse mode and backpropagation

Evaluate forwards, then propagate sensitivities backwards

Reverse Diff

Reverse Diff

Backpropagation, step by step

We want the derivative of one loss with respect to every parameter.

L(𝛉),∇𝛉L=(∂L∂θ1,…,∂L∂θn) L(\boldsymbol\theta),\qquad \nabla_{\boldsymbol\theta}L =\left(\frac{\partial L}{\partial\theta_1},\ldots, \frac{\partial L}{\partial\theta_n}\right)

Evaluate the program forwards and retain the values needed for derivatives.

Then propagate sensitivities backwards, using one local derivative at a time.

We will compute every step by hand before running the demonstration.

One graph, two paths

a=wx,b=a2,c=b+a,L=12(c−t)2a=wx,\qquad b=a^2,\qquad c=b+a,\qquad L=\tfrac12(c-t)^2

Computation graph: w and x feed a=wx; a feeds b=a squared and c=b+a; c feeds the loss. The target t also feeds the loss.

The value aa affects the loss through two paths.

We train ww. The input x=2x=2 and target t=5t=5 stay fixed.

1. Forward pass

Start with w=1w=1, x=2x=2, and t=5t=5.

Forward values: a=2, b=4, c=6, loss=0.5.

a=1⋅2=2,b=22=4,c=4+2=6a=1\cdot2=2,\quad b=2^2=4,\quad c=4+2=6 L=12(6−5)2=0.5L=\tfrac12(6-5)^2=0.5

Keep the intermediate values. The backward pass will use them.

2. What travels backwards?

The adjoint of an intermediate vv is its sensitivity:

v‾≡∂L∂v\bar v\equiv\frac{\partial L}{\partial v}

For a small perturbation, ΔL≈v‾Δv\Delta L\approx\bar v\,\Delta v.

Forward value

“What number did this operation produce?”

Backward sensitivity

“How does this number affect the loss?”

L‾=∂L∂L=1\bar L=\frac{\partial L}{\partial L}=1

Local backward rules

For an incoming sensitivity z‾\bar z:

Addition

z=u+vz=u+v u‾+=z‾\bar u\mathrel{+}=\bar z v‾+=z‾\bar v\mathrel{+}=\bar z Send the sensitivity to both inputs.

Multiplication

z=uvz=uv u‾+=vz‾\bar u\mathrel{+}=v\bar z v‾+=uz‾\bar v\mathrel{+}=u\bar z Use the other input’s forward value.

Squaring

z=u2z=u^2 u‾+=2uz‾\bar u\mathrel{+}=2u\bar z Use the local slope at the stored value.

The notation +=\mathrel{+}= means add this contribution to the adjoint.

3. Backwards through the loss

L=12(c−t)2,c=6,t=5L=\tfrac12(c-t)^2,\qquad c=6,\quad t=5

The reverse pass starts at loss with adjoint one and produces c adjoint one.

c‾=L‾∂L∂c=1⋅(c−t)=1\bar c=\bar L\,\frac{\partial L}{\partial c} =1\cdot(c-t)=1

At this point, increasing cc slightly would increase the loss.

4. Backwards through the sum

c=b+a,c‾=1c=b+a,\qquad\bar c=1

The addition sends adjoint one to b and a. The a adjoint is not complete yet.

b‾+=c‾⋅1=1,a‾+=c‾⋅1=1\bar b\mathrel{+}=\bar c\cdot1=1,\qquad \bar a\mathrel{+}=\bar c\cdot1=1

This is only the direct-path contribution to a‾\bar a.

5. Backwards through the square

b=a2,a=2,b‾=1b=a^2,\qquad a=2,\quad\bar b=1

The squared path contributes 2a times b adjoint = four to a.

a‾+=b‾∂b∂a=1⋅2a=4\bar a\mathrel{+}=\bar b\,\frac{\partial b}{\partial a} =1\cdot2a=4

Is a‾\bar a now 1, 4, or 5?

6. Accumulation at a shared node

a‾=c‾∂c∂a⏟direct path+c‾∂c∂b∂b∂a⏟path through b=1+4=5\bar a= \underbrace{\bar c\frac{\partial c}{\partial a}}_{\text{direct path}} +\underbrace{\bar c\frac{\partial c}{\partial b} \frac{\partial b}{\partial a}}_{\text{path through }b} =1+4=5

Both reverse paths meet at a, giving total sensitivity five.

Multiply derivatives along each path. Add contributions from different paths.

Only propagate further once all contributions to the node have arrived.

7. Backwards to the parameter

a=wx,w=1,x=2,a‾=5a=wx,\qquad w=1,\quad x=2,\quad\bar a=5

Backpropagation through multiplication gives w adjoint ten and x adjoint five.

w‾=a‾x=5⋅2=10,x‾=a‾w=5⋅1=5\bar w=\bar a\,x=5\cdot2=10,\qquad \bar x=\bar a\,w=5\cdot1=5

The derivative with respect to the trainable parameter is ∂L/∂w=10\partial L/\partial w=10. We can also compute x‾\bar x, even though xx is fixed during this update.

8. A parameter update

Backpropagation has finished. Now apply gradient descent with η=0.01\eta=0.01.

wnew=w−ηw‾=1−0.01⋅10=0.9w_{\mathrm{new}}=w-\eta\bar w=1-0.01\cdot10=0.9

Before After
Parameter ww 1 0.9
Prediction cc 6 5.04
Loss LL 0.5 0.0008

The new loss requires a new forward pass. A larger step may increase it.

A neuron uses the same rules

z=w1x1+w2x2+b,h=σ(z),L=12(h−t)2z=w_1x_1+w_2x_2+b,\qquad h=\sigma(z),\qquad L=\tfrac12(h-t)^2

Work backwards, starting at the scalar loss:

h‾=h−t,z‾=h‾σ′(z)\bar h=h-t,\qquad\bar z=\bar h\,\sigma'(z) w‾1=z‾x1,w‾2=z‾x2,b‾=z‾\bar w_1=\bar z\,x_1,\qquad \bar w_2=\bar z\,x_2,\qquad\bar b=\bar z

For a sigmoid, σ′(z)=h(1−h)\sigma'(z)=h(1-h): reuse the activation from the forward pass.

Backpropagation through layers

𝐳(ℓ)=W(ℓ)𝐡(ℓ−1)+𝐛(ℓ),𝐡(ℓ)=σ(𝐳(ℓ))\mathbf z^{(\ell)}=W^{(\ell)}\mathbf h^{(\ell-1)}+\mathbf b^{(\ell)}, \qquad \mathbf h^{(\ell)}=\sigma(\mathbf z^{(\ell)})

Given the incoming sensitivity 𝐡‾(ℓ)\bar{\mathbf h}^{(\ell)}:

𝐳‾(ℓ)=𝐡‾(ℓ)⊙σ′(𝐳(ℓ))\bar{\mathbf z}^{(\ell)}= \bar{\mathbf h}^{(\ell)}\odot\sigma'(\mathbf z^{(\ell)}) W‾(ℓ)=𝐳‾(ℓ)(𝐡(ℓ−1))T,𝐛‾(ℓ)=𝐳‾(ℓ)\bar W^{(\ell)}=\bar{\mathbf z}^{(\ell)} \big(\mathbf h^{(\ell-1)}\big)^T,\qquad \bar{\mathbf b}^{(\ell)}=\bar{\mathbf z}^{(\ell)} 𝐡‾(ℓ−1)=(W(ℓ))T𝐳‾(ℓ)\bar{\mathbf h}^{(\ell-1)}= \big(W^{(\ell)}\big)^T\bar{\mathbf z}^{(\ell)}

Repeat for the previous layer. Here ⊙\odot denotes elementwise multiplication.

The reverse sweep in code

# Forward: record operations and their needed input values.
value = evaluate_in_dependency_order(graph)

# Backward: start with zero contributions everywhere.
adjoint = {node: 0.0 for node in graph}
adjoint[loss] = 1.0

for node in reversed(dependency_order):
    for input_edge in node.input_edges:
        parent = input_edge.source
        adjoint[parent] += (
            adjoint[node] * local_derivative(input_edge, value)
        )

Process edges, including repeated inputs. For z=u⋅uz=u\cdot u, both edges contribute.

Checking a gradient

For our example, compute an independent central finite difference:

gFD=L(w+ε)−L(w−ε)2εg_{\mathrm{FD}}=\frac{L(w+\varepsilon)-L(w-\varepsilon)}{2\varepsilon}

At w=1w=1, x=2x=2, t=5t=5, and ε=10−5\varepsilon=10^{-5}:

gFD≈10.000000004,gbackprop=10g_{\mathrm{FD}}\approx10.000000004,\qquad g_{\mathrm{backprop}}=10

Use this as a diagnostic on a small, smooth example.

Very small finite-difference steps amplify roundoff. At a nondifferentiable point, the comparison needs special care.

One reverse pass, many parameters

For one scalar loss, the reverse sweep computes all parameter derivatives.

Each operation sends sensitivities to its inputs. The work scales with the computation graph, with a constant-factor overhead for standard operations.

The tradeoff is memory: backward rules need values from the forward pass.

Checkpointing saves selected values and recomputes others.

Backpropagation demonstration

Computation cost

Computation cost

Higher-Order Derivatives

Higher-Order Derivatives

Practical Considerations

Practical Considerations

Automatic differentiation in practice

Frameworks, transformations, compilers, and implementation trade-offs

Existing AD techniques

Existing AD techniques

AD in Major ML Frameworks

AD in Major ML Frameworks

Python ecosystem

Tool Role Differentiation interface
NumPy / SciPy Numerical arrays and scientific algorithms Derivatives supplied separately or approximated
SymPy Symbolic expressions diff(expression, variable)
JAX Array programs and function transformations jvp, vjp, grad
PyTorch Tensors, neural networks, training torch.func, .backward()
TensorFlow Tensors, models, training ForwardAccumulator, GradientTape
marimo Reactive Python notebooks Runs the chosen library’s code

Forward evaluation and forward-mode AD

For 𝐲=F(𝐱)\mathbf y=F(\mathbf x), ordinary evaluation computes 𝐲\mathbf y.

Forward-mode AD also propagates an input direction 𝐯\mathbf v:

𝐲̇=JF(𝐱)𝐯\dot{\mathbf y}=J_F(\mathbf x)\,\mathbf v

Value

z=uvz=uv

Tangent

ż=u̇v+uv̇\dot z=\dot u\,v+u\,\dot v

A Jacobian-vector product (JVP) answers: how does the output change along this input direction?

Reverse mode: an output sensitivity

Evaluate 𝐲=F(𝐱)\mathbf y=F(\mathbf x), then choose an output sensitivity 𝐮\mathbf u.

𝐱‾=JF(𝐱)T𝐮\bar{\mathbf x}=J_F(\mathbf x)^T\,\mathbf u

y, pullback = jax.vjp(F, x)
(sensitivity_x,) = pullback(u)

The pullback maps an output sensitivity back to the inputs.

For a scalar loss, seed the output with 11 to obtain its gradient.

JVP: a direction through the inputs. VJP: a weighted combination of output sensitivities.

JAX: the lecture’s scalar loss

import jax
jax.config.update("jax_enable_x64", True)

def loss(w):
    a = 2.0 * w
    return 0.5 * (a**2 + a - 5.0)**2

value, tangent = jax.jvp(loss, (1.0,), (1.0,))
value, pullback = jax.vjp(loss, 1.0)
(gradient,) = pullback(1.0)

print(value, tangent, gradient)   # 0.5, 10.0, 10.0
print(jax.grad(loss)(1.0))        # 10.0

The input direction is 11. The scalar-loss output seed is also 11.

marimo: compare the two modes

Executed marimo notebook comparing loss 0.5 and derivative 10 using JAX jvp, vjp, and grad.

Change ww from 11 to 0.90.9: predict the loss and gradient before rerunning.

A vector function

F(x1,x2)=(x1x2x12+x2),𝐱=(23),J=(3241)F(x_1,x_2)=\begin{pmatrix}x_1x_2\\x_1^2+x_2\end{pmatrix}, \qquad \mathbf x=\begin{pmatrix}2\\3\end{pmatrix}, \qquad J=\begin{pmatrix}3&2\\4&1\end{pmatrix}

Forward: select a column

𝐯=(10),J𝐯=(34)\mathbf v=\begin{pmatrix}1\\0\end{pmatrix},\qquad J\mathbf v=\begin{pmatrix}3\\4\end{pmatrix}

Reverse: select a row

𝐮=(10),JT𝐮=(32)\mathbf u=\begin{pmatrix}1\\0\end{pmatrix},\qquad J^T\mathbf u=\begin{pmatrix}3\\2\end{pmatrix}

For general seeds, compute linear combinations of columns or rows.

JAX: products and full Jacobians

import jax.numpy as jnp

def F(x):
    return jnp.array([x[0] * x[1], x[0]**2 + x[1]])

x = jnp.array([2.0, 3.0])
seed = jnp.array([1.0, 0.0])
y, Jv = jax.jvp(F, (x,), (seed,))  # [3., 4.]
y, pullback = jax.vjp(F, x)
(JTu,) = pullback(seed)            # [3., 2.]

J_forward = jax.jacfwd(F)(x)       # [[3., 2.],
J_reverse = jax.jacrev(F)(x)       #  [4., 1.]]

Both Jacobian functions return the same matrix. JVP and VJP need not build it.

marimo: input direction and output seed

Executed marimo vector example with Jacobian [[3,2],[4,1]], JVP [3,4], and VJP [3,2].

Switch each seed to (0,1)(0,1). Which column or row should appear?

PyTorch: forward and reverse transforms

import torch
from torch.func import jvp, vjp

w = torch.tensor(1.0, dtype=torch.float64)
one = torch.ones_like(w)

value, tangent = jvp(loss, (w,), (one,))
value, pullback = vjp(loss, w)
(gradient,) = pullback(one)

print(value.item())      # 0.5
print(tangent.item())    # 10.0
print(gradient.item())   # 10.0

Use the same scalar loss function. These transforms return derivatives directly.

PyTorch: a training step

w = torch.tensor(1.0, dtype=torch.float64, requires_grad=True)
optimizer = torch.optim.SGD([w], lr=0.01)

optimizer.zero_grad(set_to_none=True)
value = loss(w)                 # forward evaluation: 0.5
value.backward()               # reverse mode
print(w.grad.item())           # 10.0

optimizer.step()               # optimizer changes w
print(w.item())                # 0.9
print(loss(w).item())          # approximately 0.0008

.backward() accumulates into .grad. Clear gradients before the next step.

marimo: one optimizer step

Executed marimo PyTorch notebook: gradient ten, updated weight 0.9, loss decreases from 0.5 to 0.0008.

Increase the learning rate to 0.50.5. The gradient is still correct. What happens to the loss?

Choosing and checking an AD mode

For F:ℝn→ℝmF:\mathbb R^n\to\mathbb R^m, think about what derivative you need.

Task Usual starting point
One input direction JVP / forward mode
Scalar loss, many parameters Gradient / reverse mode
Full Jacobian, few inputs jacfwd
Full Jacobian, few outputs jacrev

Keep operations inside the library’s differentiable computation. Convert to NumPy or Python numbers after computing the derivative.

Check small examples analytically or with finite differences, and benchmark the real workload.

Example: mutations and branchings

Example: mutations and branchings

Example: differentiating LLVM

Example: differentiating LLVM

From Source to Machine Code

From Source to Machine Code

Historical and conceptual epilogue

An old algorithm, modern scale, and the cheap-gradient principle

Backpropagation is older than deep learning

1970 — Seppo Linnainmaa
Systematic reverse accumulation for differentiating a program.

1974 — Paul Werbos
Connected backward differentiation with learning systems.

1986 — Rumelhart, Hinton & Williams
Demonstrated and popularized backpropagation for multilayer neural networks.

Backpropagation is reverse-mode automatic differentiation applied to a learning problem.

A relay race passes a baton labelled chain rule from 1970 to 1974 to 1986.

The same algorithm at larger scale

1989 — handwritten ZIP codes
LeCun and colleagues trained a convolutional network with backpropagation.

1990s — document recognition
Neural systems moved into deployed cheque and document processing.

2012 — AlexNet
GPU training, about 60 million parameters, and 1.2 million ImageNet training images.

Data, compute, architecture and scale changed dramatically. The chain rule did not.

A small 1989 handwritten-digit system and a much larger 2012 GPU system use the same backpropagation principle.

The surprising fact: gradients are cheap

For a scalar objective

f:ℝn→ℝ,f:\mathbb R^n\to\mathbb R,

one reverse sweep produces the complete gradient:

cost(∇f)=O(cost(f)).\operatorname{cost}(\nabla f)=O\!\left(\operatorname{cost}(f)\right).

In practice, the backward pass costs a small constant multiple of the forward computation. It also needs stored or recomputed intermediate values.

10610^6 or 10910^9 parameters do not require 10610^6 or 10910^9 separate derivative calculations.

A scalar loss sends all parameter gradients back in one reverse sweep.

EndBackup follows

EndBackup follows

Julia AD: Enzyme

Julia AD: Enzyme

Julia AD: Zygote

Julia AD: Zygote