Backpropagation is the Adjoint Method
Backpropagation is often introduced as something developed within the field of machine learning. However, the reality is that backpropagation is just a special case of the adjoint method where the inversion of the partial derivative can be done efficiently due to its lower-triangular structure. This note is in two parts. In the first part we review the adjoint method while in the second part we describe how backpropagation is a special case of the adjoint method with a structure that results in scalable computational algorithms.
A key insight is that while the adjoint method is seemingly limited to equality constrained problems, it turns out that all computer programs are trivially equality constrained problems with equalities coming from each assignment11 All lines of code are essentially โโ which can be written as โโ. In this sense, as shown in a later section, optimizing a neural network is actually an equality constrained optimization problem with constraints coming from each layer of the network. This is opposed to how optimizing a neural network is usually presented as an unconstrained optimization problem.
1. The Adjoint Method
In many engineering applications we are interested in solving equality constrained optimization problems where the optimization variable () implicitly defines another variable () that then appears in the objective function22 A famous example of this is Topology Optimization. Written out this means solving optimization problems of the following form
(1)
Most methods for solving equality constrained optimization problems require the computation of the gradient of the objective function with respect to the optimization variable . This is where the problem comes in: Naively computing the gradient requires the computation of the sensitivities of the implicitly defined variable with respect to the optimization variable . This results in a computational bottleneck as forming the sensitivities scales as , meaning that adding a new parameter adds additional sensitivities (and similarly adding one more variable would add additional sensitivities).
To resolve this computational bottleneck the adjoint method was introduced. The first step in the derivation of the adjoint method is to introduce the Lagrangian of the objective function, i.e.
(2)
It is easy to see that whenever satisfies the equality constraint , then the Lagrangian is equal to the original objective function. The reason why the introduction of the additional term in the Lagrangian is useful is that can be set to be anything. In particular, we aim to set it in a way so that we can avoid computing the otherwise computationally expensive sensitivities . We start by computing the total derivative of the Lagrangian with respect to 33 The total derivative is the transpose of the gradient i.e. . That is multiplying the change in with the total derivative (rather than the transpose of the gradient) gives the change in the function .
(3)
Now we collect the terms that depend on the undesired , resulting in
(4)
As we can choose freely a natural idea is to set it in a way such that the term in front of the undesired term vanishes. This means that we can choose as the solution to the equation
(5)
Inserting back into the equation we find that the gradient of the Lagrangian with respect to is given by
(6)
To conclude: The adjoint method is a simple way to avoid the computational bottleneck of computing the sensitivities by cleverly computing the so-called adjoint variable in a way that eliminates the need to compute the problematic sensitivities.
Exampleย 1: Linearly constrained problem with structure
Example 1 (Linearly constrained problem with structure)ย ย In order to illustrate the adjoint method we consider a simple linearly constrained problem of the form (inspiration from problem 38 in [1] )
(7)
where is a symmetric tridiagonal matrix that depends on the parameters as
(8)
Now in order to compute the gradient of interest we start by computing the adjoint variable as
(9)
Note that in practice we do not form explicitly but rather compute by solving the linear system . Using the adjoint variable we can compute the gradient of with respect to as
(10)
In the concrete case of the derivatives of with respect to we have that
(11)
Using this it follows that the th component of the gradient can be computed as
(12)
Below is a Julia implementation of the above example along with a test against ForwardDiff.jl to verify the correctness of the implementation.
1using LinearAlgebra2using ForwardDiff3using Random4using Test56Random.seed!(42)7n = 1000 # Number of linear constraints8c = rand(n) # Objective vector9b = rand(n) # Linear equality vector10ฮธ = rand(2n-1) # Parameters of the equality constraint1112# Parametrizing linear equality constraint matrix13A(ฮธ) = SymTridiagonal(ฮธ[1:n], ฮธ[n+1:end])1415# Objective function16f(ฮธ) = (c' * (A(ฮธ) \ b))^21718# Partial derivatives19partial(i,n,ฮป,u) = i <= n ? ฮป[i]*u[i] : ฮป[i+1-n]*u[i-n] + ฮป[i-n]*u[i+1-n]2021# Gradient computation22function โf(ฮธ)23 n = length(b)24 M = A(ฮธ) # Defining constraint matrix25 u = M \ b # Computing solution26 ฮป = M \ (-2*c*(c'*u)) # Adjoint variables27 return [partial(i,n,ฮป,u) for i = 1:2n-1]28end2930# Testing against ForwardDiff31@test ForwardDiff.gradient(f,ฮธ) โ โf(ฮธ)2. Backpropagation and the Adjoint Method
Section 5.5 of [2] includes an example of how the adjoint method and backpropagation of neural networks are similar. In neural networks we are often interested in minimizing a loss function of the form
(13)
with and the notation โโ is used in order to highlight that is a constant input (i.e. most often the โdataโ) and is some regularization function (e.g. ). Furthermore is a neural network with layers that given a set of constant inputs maps the parameters to an output. Now, the above objective function does not include any equality constraints. However, one should realize that an -layer neural network is nothing more than a composition of functions
(14)
Now using the notation that is the output of the th layer of the network we can write the propagation through the network as a large nonlinear system of equations that have to be satisfied
(15)
Using this notation (and removing the explicit dependence on ) we see that optimizing a neural network is really nothing more than solving the following equality constrained optimization problem
(16)
We can now use the adjoint method to compute the gradient of with respect to . As a reminder this means that
(17)
where is referred to as the adjoint variable. Let be the dimension of the output of layer , so that the stacked state has dimension . For simplicity, we assume that the final output is scalar, i.e. . Then , where is the last canonical basis vector in . In this case we have that
(18)
Now what is left to compute is and . Starting with we note that because only propagates the โs forwards the resulting partial derivative is a lower block triangular matrix of the form
(19)
Now is a good place to stop and think of a key concept of backpropagation: The resulting computational graph must be a Directed Acyclic Graph (DAG). This is indeed equivalent to stating that should be a lower triangular matrix. This is an important property as the adjoint method requires us to invert , which can be done cheaply for a lower triangular matrix. Even more important is that the diagonal blocks are the identity, meaning that forward/backward substitutions can be done without any matrix inversions at all.
What is left is to compute , which standardly is done as
(20)
Now, in case of the layers not sharing any parameters the matrix will have a block diagonal structure, i.e.
(21)
where are the parameters of layer .
Putting everything together we find that the gradient of with respect to is given by
(22)
As noted previously both and are structured, meaning that the above can be computed efficiently.
2.1. How to compute the elements of and
For a standard linear layer an activation function is usually applied element wise. In practice this means that we really should look at the rows of separately. For the th row the gradients are easily seen to be
(23)
Using the above we see that
(24)
where is applied element wise. Now for we have to pick a way of how to vectorize the parameters. Here we choose
(25)
where vectorizes by stacking the columns of . Using the vectorization we can write the sought after sensitivity as
(26)
While the Kronecker product above does result in a sparse matrix, it is even more structured. Using the standard property of Kronecker products, , where reverses the column-stacking operation with dimensions compatible with and , its products with vectors can be computed without forming it, e.g. .
A final remark here is that the diagonal matrices that go into the elements of and are the same. That is if we define
(27)
then it follows that
(28)
with the slightly unusual notation that denotes the lower block diagonal matrix. Here its block is for , with all other blocks zero and block row and column sizes . The weight acts on the fixed input and therefore does not appear in .
Below is a Julia implementation of the above backpropagation/adjoint method for a fully connected feedforward neural network with scalar output. In this example we set , so the objective contains only the squared error. The implementation uses BlockDiagonalMatrices.jl and Kronecker.jl in order to efficiently represent the involved matrices.
1using BlockDiagonalMatrices2using LinearAlgebra3using Kronecker4using Random5using Test67Random.seed!(42)89h(x) = exp(-x) # activation function10โh(x) = -exp(-x) # derivative of activation function1112# Forward pass including the derivatives13function forward_pass(u0, layers)14 u = u015 _, b1 = first(layers)16 derivs = typeof(b1)[]17 kron_blocks = typeof([u; 1]' โ I(length(b1)))[]18 for (W, b) in layers19 push!(kron_blocks,[u; 1]' โ I(length(b))) # Lazy Kronecker product20 z = W*u + b21 u = h.(z)22 push!(derivs, โh.(z))23 end24 return kron_blocks, derivs, u25end26# Block back substitution: solves L^T y = b, i.e. y = L^(-T) b27function backsub(derivs, weights, b)28 y = Matrix(b)29 stop = length(y)30 @views for (d,Wt) in zip(reverse(derivs), reverse(transpose.(weights)))31 nprev, n = size(Wt)32 src = stop-n+1:stop33 dst = stop-n-nprev+1:stop-n34 tmp = d .* y[src]35 # Equivalent to: y[dst] .+= Wt * (d .* y[src])36 mul!(y[dst], Wt, tmp, 1, 1)37 stop -= n38 end39 return y40end41# Helper function that unpacks a vector ฮธ into Ws and bs42function unpack_ฮธ(ฮธ, layer_sizes)43 Ws, bs = Matrix{eltype(ฮธ)}[], Vector{eltype(ฮธ)}[]44 i = 145 for (n_in,n_out) in zip(layer_sizes[1:end-1], layer_sizes[2:end])46 j = i + n_out*n_in47 push!(Ws, reshape(ฮธ[i:j-1], n_out, n_in))48 i = j + n_out49 push!(bs, ฮธ[j:i-1])50 end51 return Ws, bs52end53# Evaluate the network output using the forward pass54function network_output(ฮธ, layer_sizes, u0)55 Ws, bs = unpack_ฮธ(ฮธ, layer_sizes)56 _, _, uN = forward_pass(u0, zip(Ws,bs))57 return only(uN)58end59# Gradient computation using the adjoint method60function โL(ฮธ, layer_sizes, u0; y=0.0)61 # First unpack the parameter vector62 Ws, bs = unpack_ฮธ(ฮธ, layer_sizes)63 # Forward pass includes derivative information64 kron_blocks, derivs, uN = forward_pass(u0, zip(Ws, bs))65 # S has subdiagonal blocks W_2,...,W_N; L = I - D*S and M^T = D*K66 D = Diagonal(vcat(derivs...))67 K = BlockDiagonal(kron_blocks)68 g = zeros(eltype(ฮธ), sum(layer_sizes[2:end]))69 g[end] = 2*(only(uN) - y)70 # The final step is to evaluate the gradient from the right71 grad_adjoint = (backsub(derivs[2:end],Ws[2:end],g')*D)*K72 return grad_adjoint'73end74# Forward difference to test the adjoint gradient implementation75function fd(ฮธ, layer_sizes, u0, i; e=1e-6, y=2.0)76 uN0 = network_output(ฮธ, layer_sizes, u0)77 ฮธ[i] += e78 uN1 = network_output(ฮธ, layer_sizes, u0)79 ฮธ[i] -= e80 return ((uN1 - y)^2 - (uN0 - y)^2)/e81end8283# Setting up parameters8485layer_sizes = [50,40,30,20,10,1]86N = length(layer_sizes) - 187init(sizes...) = 0.01*randn(sizes...)88Ws = [init(layer_sizes[i+1], layer_sizes[i]) for i=1:N]89bs = [init(layer_sizes[i+1]) for i = 1:N]90u0 = init(layer_sizes[1])91layers = zip(Ws, bs)9293# First we compute the forward pass94ฮธvec = vcat([[W[:]; b] for (W, b) in layers]...)9596## Testing the gradient97y = 3.0 # We aim to have the final output be 3.098grad_adjoint = โL(ฮธvec, layer_sizes, u0; y=y)99idx = 4000100grad_fd = fd(ฮธvec, layer_sizes, u0, idx; e=1e-5, y=y)101@test grad_fd โ grad_adjoint[idx] atol=1e-6102103# Optimizing to get the desired output y104for _ = 1:1000105 grad = โL(ฮธvec, layer_sizes, u0; y=y)106 ฮธvec .-= 0.001 .* grad107end108@test network_output(ฮธvec, layer_sizes, u0) โ y109final_gradient = โL(ฮธvec, layer_sizes, u0; y=y)110@test final_gradient โ zeros(length(ฮธvec)) atol=1e-10Cite this post
@misc{paltorp2025adjointmethod, author = {Paltorp, Mikkel}, title = {{Backpropagation is the Adjoint Method}}, year = {2025}, month = oct, url = {https://mipals.github.io/pdf/2025/10-25-adjoint-method/adjoint-method.pdf}, note = {Blog post}}Bibliography
- [1] P. Bright, A. Edelman, and S. G. Johnson, โMatrix Calculus (for Machine Learning and Beyond).โ [Online]. Available: https://arxiv.org/abs/2501.14787
- [2] A. Edelman, E. Akyรผrek, and Y. Wang, โBackpropagation through Back Substitution with a Backslash,โ SIAM Journal on Matrix Analysis and Applications, vol. 45, no. 1, pp. 429โ449, 2024, doi: 10.1137/22M1532871.