Lucas Vittor

Automatic differentiation

There are three methods to compute the derivative of a function on a computer. Namely, numerical, symbolic, and automatic differentiation. Numerical differentiation uses finite-difference approximations, which are simple to implement but can be highly inaccurate due to truncation and round-off errors, and it scales poorly when computing gradients with respect to millions of parameters. Symbolic differentiation addresses these weaknesses at the cost of constructing new mathematical expressions that can, in the worst case, grow exponentially—a phenomenon known as expression swell—leading to high memory consumption. In contrast, automatic differentiation, or just autodiff, decomposes a function into elementary operations with known derivatives and uses them to propagate numerical derivative values through the computation.

Now, consider a differentiable function f:RnRmf:\mathbb{R}^n\rightarrow\mathbb{R}^m with input xRn\mathbf{x}\in\mathbb{R}^n and output f(x)Rmf(\mathbf{x})\in\mathbb{R}^m. The exact derivative of ff at x\mathbf{x} is its Jacobian:

Jf(x)=f(x)xRm×n.J_f(\mathbf{x}) = \frac{\partial f(\mathbf{x})}{\partial\mathbf{x}} \in\mathbb{R}^{m\times n}.

Each differentiation method computes the Jacobian differently by treating the function ff differently. Intuitively, numerical differentiation perturbs the inputs, symbolic differentiation rewrites the mathematical expression, and automatic differentiation propagates derivative values through elementary operations.

Numerical differentiation treats ff as a black box and approximates the Jacobian by perturbing one input xiRx_i\in\mathbb{R} by a small non-zero step hRh\in\mathbb{R} and observing how the output f(x)Rmf(\mathbf{x})\in\mathbb{R}^m changes:

f(x)xif(x+hei)f(x)h.\frac{\partial f(\mathbf{x})}{\partial x_i} \approx \frac{f(\mathbf{x}+h\mathbf{e}_i)-f(\mathbf{x})}{h}.

Here, eiRn\mathbf{e}_i\in\mathbb{R}^n is the unit vector that selects xix_i. Computing the complete Jacobian therefore requires repeating this process for all nn inputs, in addition to the baseline evaluation of f(x)f(\mathbf{x}).

Symbolic differentiation treats ff as a mathematical expression and constructs a new expression for each derivative in the Jacobian:

Jf(x)=[fi(x)xj]i,jRm×n.J_f(\mathbf{x}) = \left[ \frac{\partial f_i(\mathbf{x})}{\partial x_j} \right]_{i,j} \in\mathbb{R}^{m\times n}.

The resulting matrix is therefore an exact symbolic function of xRn\mathbf{x}\in\mathbb{R}^n, but its expressions can grow significantly.

Automatic differentiation (AD) represents the evaluation of ff as a computation graph of elementary operations that describes how the input values (x1,x2,,xn)Rn(x_1, x_2, \cdots, x_n)\in\mathbb{R}^n are transformed to the output f(x)Rmf(\mathbf{x})\in\mathbb{R}^m. Suppose an operation in the graph computes zk\mathbf{z}_k from the values on which it directly depends. We write lkl \to k when zk\mathbf{z}_k directly depends on zl\mathbf{z}_l. The local Jacobian associated with this dependency is

Ak,l=zkzl,lk.A_{k, l} = \frac{\partial\mathbf{z}_k}{\partial\mathbf{z}_{l}}, \qquad l\to k.

Associating each direct dependency in the computational graph with its local Jacobian produces a linearized computation graph (LCG) that describes how small changes in the input propagate to the output.

A function from R-two to R-two written as maths, its primal computation graph, and the linearized graph whose edges carry the local derivatives.
Figure 1: Two views of the same evaluation

An instance of ff with n=m=2n = m = 2. (a) the primal graph, whose vertices are the intermediate values zk\mathbf{z}_k and whose edges run towards the outputs; (b) the linearized graph, the same shape with every edge carrying its local Jacobian Ak,lA_{k, l}. The shared product feeds both outputs, so the graph is a diamond rather than a tree. Structure after an example of Paul D. Hovland.

For a particular evaluation of ff, the executed elementary operations can be represented as a directed acyclic graph (DAG). Its nodes can therefore be processed in topological order, from the input nodes (x1,x2,,xnx_1, x_2, \cdots, x_n) to the output nodes (f1,f2,,fmf_1, f_2, \cdots, f_m), or in reverse topological order, from the outputs to the inputs.

Processing the LCG from inputs to outputs is called forward-mode. Given an input direction vRn\mathbf{v}\in\mathbb{R}^n, forward mode initializes the input change as Δx=v\Delta\mathbf{x}=\mathbf{v} and applies the chain rule at every operation:

Δzk=l:lkAk,lΔzl.\Delta\mathbf{z}_k = \sum_{l:l\to k}A_{k, l}\Delta\mathbf{z}_{l}.

After the graph has been processed, the resulting output change is the Jacobian-vector product (JVP):

Δf(x)=Jf(x)vRm.\Delta f(\mathbf{x})=J_f(\mathbf{x})\mathbf{v}\in\mathbb{R}^m.

Choosing v=ej\mathbf{v}=\mathbf{e}_j, where ej\mathbf{e}_j is the jj-th standard basis vector, produces

Jf(x)ej=f(x)xj,J_f(\mathbf{x})\mathbf{e}_j = \frac{\partial f(\mathbf{x})}{\partial x_j},

which is the jj-th column of the Jacobian. Therefore, the complete Jacobian can be computed using nn forward-mode passes (for j=1,,nj = 1, \cdots, n), and this mode is preferable when the function ff has considerably fewer inputs than outputs (nmn \ll m).

Processing the LCG from outputs to inputs is called reverse-mode. Reverse mode first evaluates f(x)f(\mathbf{x}), and then processes this recorded computation in reverse topological order.

Given an output direction uRm\mathbf{u}\in\mathbb{R}^m, reverse mode computes the derivative of the scalar function uf(x)\mathbf{u}^{\top} f(\mathbf{x}). For the output node zout=f(x)\mathbf{z}_{\mathrm{out}}=f(\mathbf{x}), reverse mode initializes

zˉout=u,\bar{\mathbf{z}}_{\mathrm{out}}=\mathbf{u},

where zˉk\bar{\mathbf{z}}_k denotes the reverse-mode adjoint associated with zk\mathbf{z}_k. It then applies the chain rule at every node in the graph:

zˉk=l:klAl,kzˉl.\bar{\mathbf{z}}_k = \sum_{l:k\to l} A_{l,k}^{\top}\bar{\mathbf{z}}_l.

After the graph has been processed, the resulting input adjoint is the vector-Jacobian product (VJP):

xˉ=uJf(x)R1×n.\bar{\mathbf{x}}^{\top}=\mathbf{u}^{\top}J_f(\mathbf{x}) \in\mathbb{R}^{1\times n}.

Choosing u=ei\mathbf{u}=\mathbf{e}_i produces

xˉ=eiJf(x)=fi(x)x,\bar{\mathbf{x}}^{\top}=\mathbf{e}_i^{\top}J_f(\mathbf{x}) = \frac{\partial f_i(\mathbf{x})}{\partial\mathbf{x}},

which is the ii-th row of the Jacobian. Similarly, the complete Jacobian can be computed using mm reverse-mode passes, and is preferable when the function ff has considerably more inputs than outputs (nmn\gg m).

Backpropagation

A special case of reverse-mode AD, called backpropagation, is when the function f:RnRmf:\mathbb{R}^n\rightarrow\mathbb{R}^m has scalar output (m=1m=1). In this case there is only one output direction, u=1R\mathbf{u}=1 \in \mathbb{R}, so the vector-Jacobian product

xˉ=uJf(x)=Jf(x)R1×n,\bar{\mathbf{x}}^{\top}=\mathbf{u}^{\top}J_f(\mathbf{x}) = J_f(\mathbf{x}) \in\mathbb{R}^{1\times n},

is the Jacobian of ff.

Implementation

Now, we will create a tiny library that does symbolic, numerical and automatic differentiation. For each method, we will differentiate the same function:

def f(x1, x2):
    z1 = x1 * x2 + Expr.relu(x1 - x2)
    return z1**2, x1 + 10 / z1

Note that f:R2R2f:\mathbb{R}^2\rightarrow\mathbb{R}^2 is not differentiable at every point, which makes this example more interesting.

Symbolic differentiation

Symbolic differentiation overloads operators so that every expression constructs an immutable expression tree. Differentiation is therefore done by recursively applying a rule associated with each node.

class Expr:
    def __init__(self, op, *args):
        self.op, self.args = op, args
 
    def diff(self, x):
        """Return the symbolic derivative with respect to x."""
        match self.op, self.args:
            case "symbol", (name,):
                return int(name == x.args[0])
            case "+", (a, b):
                return Expr._diff(a, x) + Expr._diff(b, x)
            case "*", (a, b):
                return Expr._diff(a, x) * b + a * Expr._diff(b, x)
            case "**", (a, n):
                return n * a ** (n - 1) * Expr._diff(a, x)
            case "relu", (a,):
                # H is the Heaviside step function.
                return Expr("H", a) * Expr._diff(a, x)
 
    @staticmethod
    def _diff(a, x):
        """Treat ordinary numbers as constants."""
        return a.diff(x) if isinstance(a, Expr) else 0
 
    def eval(self, values):
        """Evaluate the expression using the supplied symbol values."""
        match self.op, self.args:
            case "symbol", (name,):
                return values[name]
            case "+", (a, b):
                return Expr._eval(a, values) + Expr._eval(b, values)
            case "*", (a, b):
                return Expr._eval(a, values) * Expr._eval(b, values)
            case "**", (a, n):
                return Expr._eval(a, values) ** n
            case "relu", (a,):
                return max(0, Expr._eval(a, values))
            case "H", (a,):
                # We define H(0) = 0 although ReLU is not differentiable at
                # zero, matching the convention.
                return int(Expr._eval(a, values) > 0)
 
    @staticmethod
    def _eval(a, values):
        """Leave ordinary numbers unchanged during evaluation."""
        return a.eval(values) if isinstance(a, Expr) else a
 
    @staticmethod
    def relu(x):
        """Apply ReLU to a symbolic expression or number."""
        return Expr("relu", x) if isinstance(x, Expr) else max(0, x)
 
    def __add__(self, other): return Expr("+", self, other)
    __radd__ = __add__
 
    def __mul__(self, other): return Expr("*", self, other)
    __rmul__ = __mul__
 
    def __neg__(self): return Expr("*", -1, self)
    def __sub__(self, other): return self + -other
    def __rsub__(self, other): return other + -self
    def __pow__(self, n): return Expr("**", self, n)
    def __truediv__(self, other): return self * other**-1
    def __rtruediv__(self, other): return other * self**-1
 
    def __str__(self):
        """Return a readable mathematical representation."""
        match self.op, self.args:
            case "symbol", (name,):
                return str(name)
            case ("relu" | "H"), (a,):
                return f"{self.op}({a})"
            case op, (a, b):
                return f"({a} {op} {b})"
 
    __repr__ = __str__

With this simple Expr library we can now compute the Jacobian of ff:

x = (
    Expr("symbol", "x_1"),
    Expr("symbol", "x_2"),
)
 
fx = f(*x)
 
J = [
    [f_i.diff(x_j) for x_j in x]
    for f_i in fx
]
 
def J_f(*values):
    variables = dict(zip((x_i.args[0] for x_i in x), values))
 
    return [
        [Expr._eval(entry, variables) for entry in row] for row in J
    ]
 
print("f(x) =", fx)
# ((((x_1 * x_2) + relu((x_1 + (-1 * x_2)))) ** 2), (x_1 + ((((x_1 * x_2) + relu((x_1 + (-1 * x_2)))) ** -1) * 10)))
 
print("f(1, 2) =", f(1, 2))
# (4, 6.0)
 
print("J_f(x) =", J)
# [[(((((x_1 * x_2) + relu((x_1 + (-1 * x_2)))) ** 1) * 2) * (((x_2 * 1) + (x_1 * 0)) + (H((x_1 + (-1 * x_2))) * (((x_2 * 0) + 0) + 1)))), (((((x_1 * x_2) + relu((x_1 + (-1 * x_2)))) ** 1) * 2) * (((x_2 * 0) + (x_1 * 1)) + (H((x_1 + (-1 * x_2))) * (((x_2 * 0) + -1) + 0))))], [((((((((x_1 * x_2) + relu((x_1 + (-1 * x_2)))) ** -2) * -1) * (((x_2 * 1) + (x_1 * 0)) + (H((x_1 + (-1 * x_2))) * (((x_2 * 0) + 0) + 1)))) * 10) + ((((x_1 * x_2) + relu((x_1 + (-1 * x_2)))) ** -1) * 0)) + 1), ((((((((x_1 * x_2) + relu((x_1 + (-1 * x_2)))) ** -2) * -1) * (((x_2 * 0) + (x_1 * 1)) + (H((x_1 + (-1 * x_2))) * (((x_2 * 0) + -1) + 0)))) * 10) + ((((x_1 * x_2) + relu((x_1 + (-1 * x_2)))) ** -1) * 0)) + 0)]]
 
print("J_f(1, 2) =", J_f(1, 2))
# [[8, 4], [-4.0, -2.5]]

Note that this implementation is not smart enough to do simplification, and keeps terms like x * 0 and x * 1. For robust software that handles these nuances, take a look at Wolfram D and SymPy.

Citation

Please cite this work as:

Vittor, Lucas, "Automatic differentiation", lucasvittor.com, Aug 2026.

Or use the BibTeX citation:

@misc{vittor2026automatic,
  author = {Lucas Vittor},
  title = {Automatic differentiation},
  howpublished = {lucasvittor.com},
  year = {2026},
  month = {aug},
  url = {https://lucasvittor.com/notes/automatic-differentiation}
}