Solving neural ODEs from first principles
Aperitif
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.
Run the editors in order, top to bottom. First some helper functions are defined:
solve(f, t_start, y_start, t_end)solves with scipy'ssolve_ivp. Whent_endcomes beforet_start, it runs backwards in time.at(sol, t)is the value of a solution at time .draw(sol, color)draws a solution as a curve in the plane, growing from its start to its end.fresh()clears the canvas and zooms in on the part of the plane this page uses.
import numpy as np
from scipy.integrate import solve_ivp
blue, orange, green, gray = '#2563eb', '#ea580c', '#16a34a', '#a8a29e'
def solve(f, t_start, y_start, t_end):
return solve_ivp(f, (t_start, t_end), [y_start], dense_output=True, rtol=1e-8, atol=1e-10)
def at(sol, t):
return float(sol.sol(t)[0])
def grow(s, sol, steps):
ts = sol.t[0] + np.minimum(np.linspace(0, 1, steps), s) * (sol.t[-1] - sol.t[0])
return np.stack([ts, sol.sol(ts)[0]], axis=1)
def draw(sol, color, steps=100):
s = pie.scalar(0)
path = pie.lift(grow, pie.Array[pie.Point])(s, sol, steps, hidden=True)
lines = pie.line(path[:-1], path[1:], stroke=color)
pie.translate_scalar_to(s, 1, animate=True)
return lines
def fresh():
pie.clear()
pie.scalar(120, out='unit')
pie.scalar(-0.5, out='originX')
pie.scalar(-1, out='originY')
Consider the ODE
| initial condition | |
| parameter | |
| start time | |
| end time |
Draw its trajectory:
fresh()
c, y0, t0, t1 = 0.5, 1.0, 0.0, 2.0
def f(t, y):
return -c * y
traj = solve(f, t0, y0, t1)
draw(traj, blue)
One can evaluate at any point on the trajectory:
print(at(traj, 1.0))
One can also move along the trajectory:
def position(t):
return (t, at(traj, t))
t = pie.scalar(t0, out='t')
pie.lift(position, pie.Point)(t, fill='crimson')
pie.translate_scalar_to(t, t1, animate=True)
1. Drawing many trajectories
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.
Initial conditions from 0 to 2:
fresh()
pie.text('Trajectories for multiple values of y0', 1, -0.4, 14)
for value in np.linspace(0, 2, 5):
draw(solve(lambda t, y: -0.5 * y, 0, value, 2), blue)
Parameter values from 0 to 1:
fresh()
pie.text('Trajectories for multiple values of c', 1, -0.4, 14)
for value in np.linspace(0, 1, 5):
draw(solve(lambda t, y: -value * y, 0, 1, 2), blue)
Start times from -2 to 0:
fresh()
pie.text('Trajectories for multiple values of t0', 0, -0.4, 14)
for value in np.linspace(-2, 0, 5):
draw(solve(lambda t, y: -0.5 * y, value, 1, 2), blue)
2. Defining the problem
Let's say one wants to reach the point , drawn in red, when the trajectory ends at .
fresh()
draw(traj, blue)
target = 0.2
pie.point(t1, target, fill='red')
pie.text('target', t1 + 0.2, 0.2, 12)
Currently there is a gap between the actual value and the target:
y1 = at(traj, t1)
top = pie.point(t1, y1, fill=blue)
pie.line(top, pie.point(t1, target, hidden=True), stroke='#d97706')
pie.text('gap', t1 + 0.2, y1, 12)
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 |
3. Gradient w.r.t the initial condition
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:
3.1 Proof
- For small , we have
- Note that in the equation above, is the parameter and is the identity matrix.
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.
4. Optimizing
Let's start with the original equation: The state adjoint equation is given by:
In code, f is the right side of the ODE and adjoint is the right side of the state adjoint equation:
fresh()
c, t0, t1, target = 0.5, 0.0, 2.0, 0.2
pie.point(t1, target, fill='red')
pie.text('target', t1 + 0.2, 0.2, 12)
def f(t, y):
return -c * y
def adjoint(t, a):
return c * a
4.1 Iteration 0
- Start from the current initial condition and solve the forward ODE:
y0 = 1.0
y0_node = pie.scalar(y0, out='y0')
pie.point(t0, y0_node, fill=blue)
pie.text('current y0', t0 - 0.5, y0_node, 12)
fwd = solve(f, t0, y0, t1)
fwd_line = draw(fwd, blue)
- Evaluate
yat : we would like this point to be equal to the red target point.
y1 = at(fwd, t1)
end = pie.point(t1, y1, fill=blue)
print(y1)
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 ):
- Compute the initial adjoint :
a1 = y1 - target
print(a1)
- Solve the backward adjoint equation, from to :
adj = solve(adjoint, t1, a1, t0)
adj_line = draw(adj, orange)
- Evaluate the adjoint at . This is :
grad = at(adj, t0)
print(grad)
- Take a gradient descent step with a learning rate of . Note that the start point shifts a little bit:
y0 = y0 - grad
pie.translate_scalar_to(y0_node, y0, animate=True)
print(y0)
This completes one iteration. Here is an overview of what we did in the first iteration:
- Run a forward trajectory (blue) to calculate
- Calculate
- Run a backward trajectory (orange) to calculate
Now let's run it for a few more iterations.
Iteration 1
step_y0 repeats the steps above. It first sets the previous forward and backward trajectories to gray.
def step_y0():
global y0, fwd_line, adj_line, end
pie.set_stroke(fwd_line, gray)
pie.set_stroke(adj_line, gray)
pie.set_fill(end, gray)
fwd = solve(f, t0, y0, t1)
fwd_line = draw(fwd, blue)
y1 = at(fwd, t1)
end = pie.point(t1, y1, fill=blue)
adj = solve(adjoint, t1, y1 - target, t0)
adj_line = draw(adj, orange)
grad = at(adj, t0)
y0 = y0 - grad
pie.translate_scalar_to(y0_node, y0, animate=True)
print(f'y1 = {y1:.4f}, dL/dy0 = {grad:.4f}, new y0 = {y0:.4f}')
step_y0()
Iteration 2
step_y0()
Iteration 3
step_y0()
One can see the initial condition converging towards the optimal value and the end point of the trajectory also converging towards the target point.
5. Optimizing parameter c
For a parameter p and an ODE , the adjoint equation is quite similar to that for y:
- : the parameter adjoint, i.e. , representing how sensitively the loss changes with respect to the parameter .
- : the state adjoint, i.e. , representing how sensitively the loss changes with respect to the state .
5.1 Proof
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:
- Since the loss has no explicit dependence on the parameter at the final time (it only enters through the trajectory), the boundary condition is . Integrating backwards from to accumulates the parameter gradient:
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 .
- Sampling : these values can be sampled from the forward trajectory. This will be our first (and only) forward trajectory per iteration.
- Sampling : we still need to run the backward adjoint equation to get these values. This will be our first backward trajectory per iteration.
- Sampling : now that we have the trajectories to sample
a(t)andy(t), we can run the backward trajectory to get . This will be our second backward trajectory per iteration.
This time c is the variable being learned. Its current value is shown on the canvas:
fresh()
y0, t0, t1, target = 1.0, 0.0, 2.0, 0.2
pie.point(t1, target, fill='red')
pie.text('target', t1 + 0.2, 0.2, 12)
c = 0.5
c_node = pie.scalar(c, out='c')
def c_label(c):
return f'c = {c:.4f}'
pie.text(pie.lift(c_label, str)(c_node), 1, 1, 14)
def f(t, y):
return -c * y
def adjoint(t, a):
return c * a
5.2 Iteration 0
- Run the forward ODE:
fwd = solve(f, t0, y0, t1)
fwd_line = draw(fwd, blue)
- Get the end point of the trajectory, and the gradient of the loss w.r.t
y(T):
y1 = at(fwd, t1)
end = pie.point(t1, y1, fill=blue)
a1 = y1 - target
print(a1)
- Run the first backward ODE, the state adjoint :
adj = solve(adjoint, t1, a1, t0)
adj_line = draw(adj, orange)
- Run the second backward ODE, the parameter adjoint , starting from . At every time the solver asks for, the right side reads from the first backward trajectory and from the forward trajectory:
def accumulator(t, a_p):
return [at(adj, t) * at(fwd, t)]
acc = solve(accumulator, t1, 0.0, t0)
acc_line = draw(acc, green)
- Get the value at the end of the trajectory, which is the gradient :
grad = at(acc, t0)
print(grad)
- Perform the gradient descent step and update the text:
c = c - grad
pie.translate_scalar_to(c_node, c, animate=True)
Here is a quick overview of what we just did:
- Run a forward trajectory (blue) to calculate .
- Calculate .
- Run a backward trajectory (orange) to calculate .
- Run another backward trajectory (green) to calculate .
Now we just repeat the same procedure for a few more iterations.
Iteration 1
step_c repeats the steps above. It first sets the previous trajectories to gray.
def step_c():
global c, fwd, adj, fwd_line, adj_line, acc_line, end
pie.set_stroke(fwd_line, gray)
pie.set_stroke(adj_line, gray)
pie.set_stroke(acc_line, gray)
pie.set_fill(end, gray)
fwd = solve(f, t0, y0, t1)
fwd_line = draw(fwd, blue)
y1 = at(fwd, t1)
end = pie.point(t1, y1, fill=blue)
adj = solve(adjoint, t1, y1 - target, t0)
adj_line = draw(adj, orange)
acc = solve(accumulator, t1, 0.0, t0)
acc_line = draw(acc, green)
grad = at(acc, t0)
c = c - grad
pie.translate_scalar_to(c_node, c, animate=True)
print(f'y1 = {y1:.4f}, dL/dc = {grad:.4f}, new c = {c:.4f}')
step_c()
Iteration 2
step_c()
Iteration 3
step_c()
As you can see, the forward trajectory continues to converge towards the target point.
An implementation detail
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.
6. Optimizing start time
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:
- : the state adjoint at the start time, i.e. , which we already obtain by integrating the adjoint equation backwards to .
- : the value of the dynamics at the start point, i.e. the velocity at .
6.1 Proof
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:
- For the original ODE , this specializes to
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.
This time is the variable being learned, with a learning rate of . Its current value is shown above the start point:
fresh()
c, y0, t1, target, lr = 0.5, 1.0, 2.0, 0.2, 30
pie.point(t1, target, fill='red')
pie.text('target', t1 + 0.2, 0.2, 12)
def f(t, y):
return -c * y
def adjoint(t, a):
return c * a
t0 = 0.0
t0_node = pie.scalar(t0, out='t0')
pie.point(t0_node, y0, fill=blue)
def t0_label(t0):
return f't0 = {t0:.4f}'
pie.text(pie.lift(t0_label, str)(t0_node), t0_node, 1.25, 12)
6.2 Iteration 0
- Run the forward ODE:
fwd = solve(f, t0, y0, t1)
fwd_line = draw(fwd, blue)
- Get the end point of the trajectory, and the gradient of the loss w.r.t
y(T):
y1 = at(fwd, t1)
end = pie.point(t1, y1, fill=blue)
a1 = y1 - target
print(a1)
- Run the backward adjoint ODE :
adj = solve(adjoint, t1, a1, t0)
adj_line = draw(adj, orange)
- Compute the gradient :
grad = at(adj, t0) * c * y0
print(grad)
- Perform the gradient descent step, and update the start point and text:
t0 = t0 - lr * grad
pie.translate_scalar_to(t0_node, t0, animate=True)
print(t0)
Iteration 1
step_t0 repeats the steps above. It first sets the previous trajectories to gray.
def step_t0():
global t0, fwd_line, adj_line, end
pie.set_stroke(fwd_line, gray)
pie.set_stroke(adj_line, gray)
pie.set_fill(end, gray)
fwd = solve(f, t0, y0, t1)
fwd_line = draw(fwd, blue)
y1 = at(fwd, t1)
end = pie.point(t1, y1, fill=blue)
adj = solve(adjoint, t1, y1 - target, t0)
adj_line = draw(adj, orange)
grad = at(adj, t0) * c * y0
t0 = t0 - lr * grad
pie.translate_scalar_to(t0_node, t0, animate=True)
print(f'y1 = {y1:.4f}, dL/dt0 = {grad:.4f}, new t0 = {t0:.4f}')
step_t0()
Iteration 2
step_t0()
Iteration 3
step_t0()
In just a few iterations, the end point of the trajectory is pretty close to the target already.
7. Optimizing the engineering part
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.
7.1 Run all equations together (one augmented ODE)
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:
- Why: one solver pass instead of two or three means fewer function evaluations and a single, consistent time grid for every component.
- Reference: this is exactly the backward pass of the Neural ODE paper, which stacks and integrates them together. The component here is their parameter-gradient term.
7.2 Use a VJP instead of hand-derived gradients
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.
- Why: a VJP costs about the same as one evaluation of
f, regardless of the number of parameters, and removes error-prone manual derivatives. - Reference: reverse-mode autodiff / VJPs are standard in JAX (
jax.vjp) and PyTorch (torch.autograd.grad).