Skip to content
AI360XpertBeta

Automatic Differentiation

Frameworks do not solve derivatives symbolically or estimate them numerically. They apply the chain rule mechanically to the operations you actually ran, which is exact and cheap.

Forward values 6 and 0.9093 sum to 6.9093 while one reverse sweep gives both partials 2.5839 and 2.0
Forward values 6 and 0.9093 sum to 6.9093 while one reverse sweep gives both partials 2.5839 and 2.0

Why Does This Exist?

Nobody derives the gradient of a modern network by hand, and nobody needs to. You write a forward pass in Python and .backward() produces exact gradients for every parameter. That is not magic and it is not symbolic algebra. It is a specific technique with a specific cost model, and knowing the model tells you why some things are fast and others surprisingly are not.

Three concrete payoffs:

  • Why training costs about two forward passes, regardless of parameter count. Reverse mode gets every gradient in one sweep. If it scaled per parameter, training a 7-billion-parameter model would be impossible rather than merely expensive.
  • Why jacrev and jacfwd both exist, and how to pick. Choosing wrong can cost a thousandfold.
  • Why a custom backward is sometimes necessary, and what you promise when you write one.

Autodiff is also the reason research moves quickly: you change an architecture and get correct gradients without re-deriving anything. The chain rule it mechanises and the graphs it records live in computational graphs, while the object it produces is in gradients.

The one idea you need first

Derivatives compose by the chain rule: if yy depends on uu and uu depends on xx, then dy/dx=(dy/du)(du/dx)dy/dx = (dy/du)(du/dx), where dy/dudy/du is the outer slope and du/dxdu/dx is the inner slope. A gradient collects one such rate per parameter. The full rule with worked chains is in chain rule.

Think of It Like This

Costing a recipe forwards or backwards

A bakery runs flour into dough, dough into loaves, and loaves into a day's revenue.

Ask how a 1p flour rise moves everything downstream and you work forwards. Flour shifts dough cost, which shifts loaf cost, which shifts revenue. One sweep gives that one ingredient's effect on every quantity. Yeast needs another sweep.

Ask why revenue dropped and how much each ingredient contributed, and you work backwards. Start at revenue, attribute to loaves, then dough, then every ingredient at once. One sweep gives every ingredient.

Many inputs with one output, which is exactly a loss over parameters, means backwards wins by the number of inputs. The catch is that backwards needs the intermediates kept. You cannot attribute revenue to flour without knowing how much dough there was.

How It Actually Works

Autodiff is neither of the two things people assume.

It is not symbolic differentiation. Symbolic tools manipulate expressions and produce formulas, which suffer expression swell: the symbolic derivative of a deep composition is enormous and slow to evaluate.

It is not numerical differentiation. Finite differences approximate, need a step size, cost one evaluation per input, and lose precision to cancellation described in machine epsilon.

Autodiff is exact and cheap because it applies the chain rule to the sequence of operations that ran. Every primitive such as add, multiply, exp, and matmul has a known local derivative, and the framework composes them numerically as it goes. No formula is produced and no approximation is made: the result is exact to floating-point precision.

Two directions, one rule

Forward mode propagates derivatives alongside values, from inputs to outputs. Each variable carries a pair of its value and its derivative with respect to one chosen input. One sweep gives ∂(everything)/∂xj\partial(\text{everything}) / \partial x_j for a single jj, where xjx_j is the seeded input.

The clean implementation is dual numbers: represent each quantity as a+bϵa + b\epsilon where ϵ2=0\epsilon^2 = 0, with aa the value and bb the derivative. Then (a+bϵ)(c+dϵ)=ac+(ad+bc)ϵ(a + b\epsilon)(c + d\epsilon) = ac + (ad + bc)\epsilon, and ad+bcad + bc is exactly the product rule.

Reverse mode runs the function forward while recording a computational graph, where the graph is the tape of intermediates, then walks it backwards accumulating adjoints. One sweep gives ∂(one output)/∂(everything)\partial(\text{one output}) / \partial(\text{everything}).

The cost model that decides everything

For a function with nn inputs and mm outputs, in units of one function evaluation, where nn counts inputs and mm counts outputs:

  • Forward mode costs about nn sweeps for the full Jacobian. Cheap when nn is small.
  • Reverse mode costs about mm sweeps. Cheap when mm is small.

Training has nn in the billions and m=1m = 1, because the loss is a scalar. Reverse mode gets the entire gradient in one sweep, while forward mode would need one per parameter. That asymmetry is the single most important fact about autodiff, and it is why backpropagation is just reverse mode applied to a network.

The trade is memory. Reverse mode must retain intermediate values for the backward pass, so activation memory scales with depth and batch size. Forward mode needs no tape at all, which is why it is used for Jacobian-vector products and for verifying gradients on small inputs.

Where the abstraction leaks

Autodiff is exact for the operations it sees, and three situations break that:

  • Non-differentiable points such as ReLU at exactly zero, abs, max, and rounding. Frameworks pick a subgradient by convention and carry on silently.
  • Control flow on data. A dynamic framework differentiates the branch that ran, which is correct for that input and means the gradient is only valid locally.
  • Operations outside the framework such as a NumPy call, a C extension, a simulator, or a discrete sampling step. The chain silently stops and you get zero gradient rather than an error.

That last one is the expensive failure, and it is what custom backward implementations and reparameterisation tricks exist to repair.

Worked example

Take f(x1,x2)=x1x2+sin⁡(x1)f(x_1, x_2) = x_1 x_2 + \sin(x_1) at (2,3)(2, 3), where x1x_1 and x2x_2 are inputs and ff is the scalar output.

Forward pass, recording each intermediate vv, where vv names a stored value:

  • v1=x1=2v_1 = x_1 = 2
  • v2=x2=3v_2 = x_2 = 3
  • v3=v1v2=6v_3 = v_1 v_2 = 6
  • v4=sin⁡(v1)=sin⁡(2)≈0.9093v_4 = \sin(v_1) = \sin(2) \approx 0.9093
  • v5=v3+v4≈6.9093v_5 = v_3 + v_4 \approx 6.9093

Reverse pass, starting from ∂f/∂v5=1\partial f / \partial v_5 = 1 and walking back, where vˉ\bar v means ∂f/∂v\partial f / \partial v:

  • Addition sends its incoming derivative to both inputs unchanged: vˉ3=1\bar v_3 = 1 and vˉ4=1\bar v_4 = 1.
  • v4=sin⁡(v1)v_4 = \sin(v_1) has local derivative cos⁡(v1)\cos(v_1), contributing vˉ4cos⁡(2)=−0.4161\bar v_4 \cos(2) = -0.4161 to vˉ1\bar v_1, where cos⁡(2)≈−0.4161\cos(2) \approx -0.4161.
  • v3=v1v2v_3 = v_1 v_2 has local derivatives v2v_2 and v1v_1, contributing vˉ3⋅3=3\bar v_3 \cdot 3 = 3 to vˉ1\bar v_1 and vˉ3⋅2=2\bar v_3 \cdot 2 = 2 to vˉ2\bar v_2.

Note v1v_1 receives contributions from two paths, and they add:

∂f∂x1=3+cos⁡(2)≈3−0.4161=2.5839,∂f∂x2=2\frac{\partial f}{\partial x_1} = 3 + \cos(2) \approx 3 - 0.4161 = 2.5839, \qquad \frac{\partial f}{\partial x_2} = 2

Verify analytically: ∂f/∂x1=x2+cos⁡(x1)=3+cos⁡2\partial f/\partial x_1 = x_2 + \cos(x_1) = 3 + \cos 2 and ∂f/∂x2=x1=2\partial f/\partial x_2 = x_1 = 2.

The cost comparison is the point. Reverse mode produced both partials in one backward sweep. Forward mode would need two sweeps, one seeded on x1x_1 and one on x2x_2. With two inputs that is a factor of 22, and with 7 billion it is the difference between possible and not.

Code

import math

class Dual:    """Forward mode: value plus derivative, propagated by the arithmetic itself."""
    def __init__(self, val: float, der: float = 0.0):        self.val, self.der = val, der
    def __mul__(self, o: "Dual") -> "Dual":        return Dual(self.val * o.val, self.val * o.der + self.der * o.val)
    def __add__(self, o: "Dual") -> "Dual":        return Dual(self.val + o.val, self.der + o.der)

def sin_dual(d: Dual) -> Dual:    return Dual(math.sin(d.val), math.cos(d.val) * d.der)

# Seed the derivative on x1 only: one forward sweep gives df/dx1.x1, x2 = Dual(2.0, 1.0), Dual(3.0, 0.0)out = x1 * x2 + sin_dual(x1)print(round(out.val, 4), round(out.der, 4))      # -> 6.9093 2.5839
# Seeding x2 instead needs a SECOND sweep -- that is the cost of forward mode.x1, x2 = Dual(2.0, 0.0), Dual(3.0, 1.0)print(round((x1 * x2 + sin_dual(x1)).der, 4))    # -> 2.0
print(round(3 + math.cos(2), 4), 2.0)            # -> 2.5839 2.0  analytic check

2.58392.5839 and 2.02.0 match the hand calculation, and the analytic check confirms them. The two separate seedings are the forward-mode cost made explicit: one sweep per input, where reverse mode would have taken one sweep total.

Watch Out For

Breaking the chain with an operation the framework cannot see

Autodiff only differentiates operations it recorded. Step outside the framework and the chain ends silently.

The routes are ordinary. Converting to NumPy and back with .numpy() or .detach(). Calling a C extension or an external simulator. Using .item() mid-computation. In every case the forward value 6.90936.9093 stays correct while the gradient is zero or missing, so a parameter never updates.

Symptom is a subset of parameters staying at initial values while loss still falls, driven by connected parameters. Assert gradients exist and are nonzero after the first backward pass, and verify custom backwards with gradcheck in float64.

Choosing the wrong mode for the shape of your problem

Forward and reverse mode compute the same derivatives at wildly different costs, and cost depends only on input count nn and output count mm.

jacrev costs one sweep per output, while jacfwd costs one per input. For 55 inputs and 10,00010{,}000 outputs, jacfwd is about 2,0002{,}000 times cheaper, yet people reach for jacrev by habit because reverse mode is what they associate with autodiff.

The habit is to count nn and mm before choosing. Fewer outputs means reverse, and fewer inputs means forward. jacfwd(jacrev(f)) is the standard Hessian because the gradient has many inputs and one output.

The Quick Version

  • Autodiff is neither symbolic nor numerical. It applies the chain rule to the operations that actually ran, exactly.
  • Every primitive has a known local derivative, and the framework composes them numerically.
  • Forward mode carries derivatives with values, costs one sweep per input, and needs constant memory. Dual numbers are the clean implementation.
  • Reverse mode records a graph forward, walks it back, costs one sweep per output, and needs the tape, so memory grows with depth.
  • On x1x2+sin⁡(x1)x_1 x_2 + \sin(x_1) at (2,3)(2, 3), forward values 66 and 0.90930.9093 sum to 6.90936.9093, and one reverse sweep gives 2.58392.5839 and 2.02.0.
  • Cost is nn sweeps forward and mm sweeps reverse. Training has nn in the billions and m=1m = 1.
  • Training therefore costs about two forward passes regardless of parameter count.
  • Backpropagation is reverse-mode autodiff applied to a neural network.
  • The chain breaks silently at NumPy conversions, .detach(), external code, and discrete sampling. Assert nonzero gradients after the first step.
  • Non-differentiable points get a convention, not an error. Verify custom backwards with gradcheck in float64.