This article shows how to optimize the input variables of a differential equation using gradient descent. The crux is computing the gradients of the loss w.r.t these variables without backpropagating through the solver - this is the adjoint sensitivity method popularized by the Neural ODEs paper. It is also a poorly understood method in the ML community in my experience.
Three different components of a simple ODE are optimized. I work through it the way one would with pen and paper, prioritizing correctness over efficiency. Doing so illuminates some core ideas that efficient machinery tends to hide. The proofs lean on perturbation theory and are, IMHO, easier to follow.
Consider the ODE
| initial condition | |
| parameter | |
| start time | |
| end time |
One can ask how the trajectory varies with input variables like the initial condition , parameter or the start time . One way to get an intuition is by drawing multiple trajectories for different values of etc:
| Array variable | Command |
|---|---|
y0 = gm.linspace(0, 2, 5) |
|
c = gm.linspace(0, 1, 5) |
|
t0 = gm.linspace(-2, 0, 5) |
Let's say one wants to reach the point when the trajectory ends at .
Currently there is a between the actual value and the target. Is there a way to use this gap as a loss and use gradient descent to update one of the input variables above to reduce the loss? Yes.
One can calculate the loss and its gradient at the end of a trajectory and backpropagate it through the trajectory to modify the inputs to the ODE.
Note that calculating the gradient of a scalar like loss w.r.t the output , , is trivial. The tricky part is calculating the gradient w.r.t variables like:
| Variable | Gradient |
|---|---|
| Initial value | |
| Parameter | |
| Start time |
The gradient of the loss w.r.t at any time is . It is trivial to calculate and our goal is to calculate . This problem has a closed form solution:
Applying chain rule, we have:
Taking the limit , we get the adjoint equation:
This is an ODE for that can be solved backwards in time from to obtain .
Once we have the gradient w.r.t , we can update it using gradient descent and repeat the process.
Let's start with the original equation: The state adjoint equation is given by:
Maintain a separate initial condition for each iteration : y0it{k}.
The loss function is defined to be so that . Now it is time to set up the adjoint equation and run it backwards in time (from to ):
grad0 is the This completes one iteration. Here is a an overview of what we did in the first iteration:
Now let's run it for a few more iterations:
grad1 is the grad2 is the grad3 is the One can see the initial condition converging towards the optimal value and the end point of the trajectory also converging towards the target point.
cFor a parameter p and an ODE , the adjoint equation is quite similar to that for y:
Define an augmented variable that stacks the state with the parameter. Since the parameter is constant in time (), its dynamics are
For small , exactly as before:
Define the augmented adjoint , where and . Applying the chain rule identically to the earlier argument gives the augmented adjoint equation:
The Jacobian of is block-structured because the parameter rows of are zero:
Substituting and expanding the two blocks:
The first block recovers the state adjoint equation from before, while the second block gives the parameter adjoint equation:
Coming back to the original ODE , the parameter adjoint equation is:
To solve this equation, for each time step , we will need the values and .
a(t) and y(t), we can run the backward trajectory to get . This will be our second backward trajectory per iteration.
a-samp0y-samp0It is important to note that a-samp0 and y-samp0 will be updated for different values of time t as the parameter adjoint in the steps below is run.
Here is a quick overview of what we just did:
Now we just repeat the same procedure for a few more iterations:
As you can see, the forward trajectory continues to converge towards the target point.
You might be thinking that running two backward ODEs sounds inefficient. In practice, one can create an "augmented" ODE where both and are calculated on the go. More on this below.
Unlike , or , the start time is not a quantity carried inside the trajectory; it only sets where integration begins. Its gradient turns out to be especially simple:
Here we use a clever trick - perturb initial time to which leads to different values at time . From that time onwards, it can be seen as a perturbation in the "initial condition" at and the state adjoint equation can be applied to it.
Hold the initial value fixed and shift the start time by a small . We compare two trajectories at the same later time :
Advancing the start time therefore perturbs the state at by
From onward both trajectories obey the same dynamics, so the effect on the loss is captured by the state adjoint :
Dividing by and taking the limit gives the start-time gradient:
Only one backward ODE is required: once the backward state adjoint pass reaches , the gradient is just a product of the adjoint, the parameter and the state at the start point.
For each iteration , we define a unique start time t-init{k}.
In just a few iterations, the end point of the trajectory is pretty close to the target already.
So far we ran each equation as its own ODE solve, which is clear to follow but wasteful. We also cheated by working out the gradients , etc. by hand, which is not always possible once f gets complicated. Two changes make this practical at scale.
Instead of one solve for the state adjoint and a second solve for the parameter accumulator , stack everything into a single state and solve it once, backwards in time:
We wrote out and by hand. This does not scale once f has many parameters (e.g. a neural net). The adjoint only ever needs the products and i.e. a vector–Jacobian product (VJP) which autodiff computes directly without forming the full Jacobian.
f, regardless of the number of parameters, and removes error-prone manual derivatives.jax.vjp) and PyTorch (torch.autograd.grad).