In this post, I’ll explain the basics of algorithmic differentiation (AD), a.k.a. automatic differentiation, which is a technique for programmatically evaluating derivatives of mathematical functions.
This is (again) the first part of a multi-part post. In this part I’ll just explain first order, “forward mode” AD. I will cover “reverse mode” AD in a second post and possibly higher order derivatives in a third. (Apologies to anybody waiting for part 2 of the sigma-algebras article. I promise I will finish that, but I can’t promise when. The topic of this article is directly relevant to something I’m currently working on.)
AD differs from the two other main ways of programmatically calculating derivatives, which are symbolic differentiation and numerical differentiation. Symbolic differentiation involves the manipulation of a symbolic expression, such as , applying the rules of differentiation to find an expression for the derivative, in this example. Numerical differentiation instead involves invoking (multiple times) a computer program that implements the mathematical function in question, passing in slightly different input values each time and using the results to compute an approximation of the derivative.
AD resembles symbolic differentiation in that it involves applying the rules of differentiation rather evaluating the function for multiple different input values. However, it also resembles numerical differentiation in that the function to be differentiated is provided in the form of executable computer code, consisting of variable assignments, function calls, etc, rather than as a symbolic expression. Of course, these comments also imply that AD differs from both of these approaches. It is a third, distinct technique.
AD offers major benefits in terms of performance, numerical stability and ease of representation. It also has certain drawbacks, but despite these it is a very attractive option for programmatic differentiation, and is often preferable to the alternatives. I’m not going to go into the pros and cons here, though. The purpose of this article is just to explain how AD works, hopefully in a way that will make sense to people whose linear algebra and vector calculus is as rusty as mine…
1. Forward Mode
There are two main approaches to AD: forward mode (a.k.a. “tangent linear mode”) and reverse mode (a.k.a. “adjoint mode”). The former is the simplest to explain, so I’ll start there.
To set the scene, imagine we have a vector function . We have a programmatic implementation of this function, i.e. some code that evaluates for a given input vector , and we are going to be interested in evaluating the partial derivatives of the elements of the output with respect to the elements of the input. For now, though, we’ll look at some very basic vector calculus relating to . The relevance of this to AD will become clear a little further on.
The Jacobian matrix of for a given , is an matrix of partial derivatives, which we’ll write :
The Jacobian matrix, being an matrix, itself corresponds to a linear function from to , which I will refer to as the Jacobian function, or simply the Jacobian. I will use the same symbol for the Jacobian function as for the Jacobian matrix: , . Throughout this article, unless I explicitly state otherwise, whenever I refer to the Jacobian I will always be talking about the function, rather than the matrix. I won’t have much cause to talk about matrices.
The Jacobian function can be considered a linear approximation to the original function, , around the point . More precisely, if you move along a straight line in from point to point the image under will move along a (potentially curved) path from point to point , where . For a sufficiently small movement , the movement is approximated by . This is the multidimensional equivalent of moving along the tangent line as an approximation of moving along a curve in single-variable calculus.
An alternative interpretation of the function is as a way of obtaining directional derivatives, which are a generalisation of partial derivatives. If , then the partial derivative is the rate of change of the element as moves in the direction of the axis of the input space, . Similarly, given any direction in , the directional derivative (in that direction) of with respect to is a vector whose component is the rate of change of as moves in the given direction.
The function , can be used to evaluate directional derivatives by passing in unit vectors. Given a unit vector pointing in some direction, is the directional derivative of at , in the direction of . (In single-variable calculus, if you move along the tangent line until you’ve moved one unit along the x axis, the distance moved on the y axis is equal to the gradient.) As you’d expect, if lies along one of the axes, the components of the directional derivative are the partial derivatives along that axis.
So, how does this relate to AD? The approach taken in forward-mode AD is to take a piece of code that evaluates the function and augment it, so that in addition to evaluating for a particular input vector , it also evaluates the Jacobian function for some requested increment vector , at the same time. The reasons for doing this are twofold:
- Because it gives us the derivatives! By passing in some unit vector of interest as , we can programmatically obtain the directional derivative of at in the direction of that vector. By passing in unit vectors along the axes we are interested in, for example, we can obtain the partial derivatives along these axes.
- Because we can! It turns out that we can perform this augmentation of the code without significantly altering its structure.
1.1. Program Structure
To expand on point 2 above, imagine that a computer program is composed of functions like the one pictured below, which takes some inputs and produces some outputs.
Given a function like this, we can imagine a corresponding box for evalauting the Jacobian function
The first thing to note is that the second box has twice as many inputs as the first, and they appear in blue/black pairs. The black inputs are intended to receive the same values as are given to the inputs of the original function, , i.e. they represent . Clearly this is needed because each point in “input space” carries its own, distinct, Jacobian function (we differentiate at a particular point). The corresponding blue inputs are the components of the inputs to the Jacobian function, i.e. they represent , an increment vector in input space. The blue outputs are the result of evaluating the Jacobian function, i.e. an (approximated) increment in “output space”.
Now, say there’s a second function, , to which some of the outputs of are passed, and suppose that we’ve also got the corresponding box for evaluating the Jacobian of :
How can we hook things up so that we can evaluate the overall Jacobian of the whole program? It’s easy: for each output of that is passed to , we just need to pass the corresponding output of to the corresponding input of . We end up with one blue arrow in the lower diagram for each black arrow in the upper diagram:
The reason for the correspondance between the two diagrams is that the Jacobian function of the composition of two functions is simply the composition of the Jacobians of the individual functions, which is one way of stating the “chain rule” from differential calculus. In other words, if
This is unsurprising, when you think about it: the Jacobians are linear approximations of the functions themselves, so you might expect them to compose in the same way. This parallel between composing functions and composing their corresponding Jacobians is what really makes AD tick, as it means that we don’t need to change the overall function-by-function, module-by-module structure of a computer program in order to make it evaluate Jacobians.
As with , the black inputs of are supposed to receive the same inputs as itself, i.e. two of the outputs of . I won’t draw those arrows in, to avoid spaghetti, but it is important to note that they are implicitly there. They are needed because we need to know the point in ‘s input space at which the Jacobian of is being evaluated, not just the increment in input space (blue inputs) that we are interested in.
The fact that ‘s third output is not passed to poses no issues. Since the original function is unaffected by this value, so is its Jacobian linear approximation, so we simply leave the corresponding output in the lower diagram similarly unconnected. “Fan in” and “fan out” similarly pose no problems:
For the “fan out” case on the left, we can simply ignore either or and the connections leading to it, and consider the other in isolation. For the “fan in” case on the right, just observe that mathematically, and can be considered together as a single function with 4 inputs and 6 outputs. In both cases, we just end up drawing the same connections in the Jacobian diagram as exist in the original.
So, when faced with a more complex program consisting of a network of functions, like the one below, given corresponding Jacobian functions it’s clear how we can proceed. As we evaluate the program itself, whenever we evaluate a function, we also evaluate the corresponding Jacobian, passing in both the parameters we give to the original function and the corresponding blue outputs from the Jacobians we have evaluated in previous steps.
(Here, any inputs with no incoming arrows should be seen as overall inputs to the whole program, and any outputs without arrows should be seen as overall outputs. Every function has 2 inputs and 3 outputs because I’m lazy.)
It’s important to note that the mathematical “functions” above and their corresponding Jacobians aren’t necessarily “functions” in the computing sense. The above picture is a mental model of the computer program, and it can map onto the reality in a variety of different ways (and often in different ways for different parts of the program). For example, one such “function” might be a single step in an iterative loop, or a single line of code performing a multiplication operation. Similarly, the “passing” of data between these “functions” might correspond to reading/writing memory locations, or even networks or external storage, rather than passing parameters to subroutines.
Equally importantly, this mental model has a “nesting” behaviour. You might picture a network like the one above representing the program at a particular level of complexity, and then “zoom in” to a particular function to reveal that it is composed of sub-functions. The whole function’s Jacobian can be evaluated based on the Jacobians of the individual sub-functions.
What is needed in order for forward-mode AD to work is for a means to be supplied of evaluating Jacobians at some level of detail. We might choose, in one area of code, to implement individual Jacobian functions in a very finegrained way, and in another area to implement the Jacobian for a relatively large area of code as a single unit.
1.2. Implementation by Overloading
One common way to implement AD is by using the “overloading” facility provided by some programming languages, which allows calls to functions of the same name (or to operators such as + and *) to be routed to different implementations based on the types of the arguments.
Going back to our original diagram:
It’s possible to collapse this down and have a single function that evaluates both and simultaneously:
Now each black output is paired with its corresponding blue Jacobian output, which simplifies things somewhat. In this way, you could draw the 6 node “whole program” diagram above as a single network, with each connection propagating both the original function result and the Jacobian result that goes with it. (The reason I didn’t actually do that earlier is that I didn’t want to give the impression that you are required to use this approach. There’s no reason the code to evaluate the Jacobian can’t be separate from the original function, and there are reasons why you might deliberately choose to have this separation in particular applications. However, the approach of bundling and together certainly does work and it does rather simplify the diagrams.)
The operator overloading approach uses exactly this kind of bundling. It also makes use of a “fine-grained” approach to Jacobians, providing functions to evaluate the Jacobians only for the most basic operations, such as arithmetic operations and elementary maths functions like ln(), cos(), etc.
To allow Jacobians to be evaluated with minimal changes to the code, an augmented floating point type is provided, which is just a pair of floating point numbers. This type serves two purposes:
- It allows a value and the corresponding increment (a black square and the corresponding blue square) to be carried in a single variable.
- It allows calls to basic operations to be automatically routed to the augmented versions of their functions by means of overloading.
In languages that support it, code can often be switched over to use overloading-based AD simply by replacing all uses of the basic floating point type with the augmented type.
Below is an extremely simplistic implementation of forward-mode AD by means of operator overloading in Python, purely for the purposes of illustration. I’m using Python here for convenience and simplicity, but I should give the caveat that you would be very unlikely to implement AD this way in the real world in a language like Python. The reason for this is that Python is dynamically typed, so the resolution of the calls to things like __add__ is done at run time. What would, without the AD, be a simple addition of two Python floats involves, in the code below, a blizzard of dictionary lookups and function calls. The resulting slowdown is likely to be extremely severe. In a language like C++, this resolution can be done at compile time, completely avoiding this runtime cost.
class ADForwardFloat(object): def __init__(self, val, delta): # The "actual" value (black square) self.val = val # The corresponding increment (blue square) self.delta = delta # Only implementing addition and multiplication here, and assuming # that both operands are always ADForwardFloats (no mixing AD and # ordinary floats). The formulae can be checked by working out the # Jacobian matrix of each function on paper. def __add__(self, other): # If F(x,y) = x + y, then J_F,x,y(delta_x, delta_y) = delta_y + delta_x return ADForwardFloat(self.val + other.val, other.delta + self.delta) def __mul__(self, other): # If F(x,y) = x * y, then J_f,x,y(delta_x, delta_y) = y*delta_x + x*delta_y return ADForwardFloat(self.val * other.val, other.val * self.delta + self.val * other.delta) # Now let's implement a function of three variables: x^2 + xy + xz def func1(x,y,z): return x * x + x * y + x * z # Invoke the function with "ordinary" floats just gives us the result print func1(3.0,4.0,5.0) # prints 36.0 # Now let's get the partial derivative of func1 with respect to x. To # do this, we will need to pass in a unit vector pointing along the x # axis as the increment for evaluating the Jaobian against, therefore # we pass in 1 when creating the ADForwardFloat for x, and 0 for the # others. x = ADForwardFloat(3.0, 1.0) y = ADForwardFloat(4.0, 0.0) z = ADForwardFloat(5.0, 0.0) result = func1(x,y,z) print result.val # prints 36.0 print result.delta # prints 15.0, which is 2x+y+z
1.3. Other Implementation Options
It’s a mistake to think of AD as “an operator overloading trick”. Operator overloading is just one way of evaluating Jacobians, and it’s not always the best choice. There are at least two alternatives:
- Programmatically apply a transformation to the code itself, automatically generating code that can evaluate Jacobians.
- Hand-write the Jacobian evaluation code.
The latter approach may sound like a pain, but it can often be the most practical. Different methods can, of course, be used to evaluate Jacobians in different sections of code within the same program, with simple function composition used to combine these into the overall Jacobian.
1. A lot of explanations of AD describe it in terms of Jacobian matrices rather than Jacobian functions, and therefore end up writing out equations and talking about the associativity of matrix multiplication. This seems entirely pointless, as it just obscures what’s going on. Forward-mode AD is about the creation and composition of Jacobian functions. Matrices and matrix multiplications are just one way of representing/implementing these linear functions and the act of composing them, and they’re not even used all that frequently in real world forward-mode AD. Composition, for example, is more commonly achieved by simply passing the result of one function to the input of another than by performing a matrix multiplication. There’s really no benefit in using the matrix representation to explain the underlying maths.
2. Of course, in real life things aren’t so simple. Operator overloading is far from a magic wand that can be waved over existing code, and there are many reasons you might choose not to use it in a given application of AD.