unitgridOpacitycanvasColororiginXoriginY

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 dydt=f(t,y)\frac{dy}{dt} = f(t, y) with scipy's solve_ivp. When t_end comes before t_start, it runs backwards in time.
  • at(sol, t) is the value of a solution at time tt.
  • draw(sol, color) draws a solution as a curve in the (t,y)(t, y) 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

dydt=−cy\frac{dy}{dt} = -cy
initial condition y0=1y_0 = 1
parameter c=0.5c = 0.5
start time t0=0t_0 = 0
end time t1=2t_1 = 2

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 yy 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 y0y_0, parameter cc or the start time t0t_0. One way to get an intuition is by drawing multiple trajectories for different values of y0y_0 etc.

Initial conditions y0y_0 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 cc 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 t0t_0 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 (t,y)=(2,0.2)(t,y) = (2,0.2), drawn in red, when the trajectory ends at t1t_1.

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 LL w.r.t the output y1y_1, ∂L∂y1\frac{\partial L}{\partial y_1}, is trivial. The tricky part is calculating the gradient w.r.t variables like:

Variable Gradient
Initial value y0y_0 ∂L∂y0\frac{\partial L}{\partial y_0}
Parameter cc ∂L∂c\frac{\partial L}{\partial c}
Start time t0t_0 ∂L∂t0\frac{\partial L}{\partial t_0}

3. Gradient w.r.t the initial condition y0y_0

The gradient of the loss w.r.t yy at any time tt is ∂L∂y(t):=a(t)\frac{\partial L}{\partial y(t)} := a(t). It is trivial to calculate a(t1)=∂L∂y(t1)a(t_1) = \frac{\partial L}{\partial y(t_1)} and our goal is to calculate a(t0)=∂L∂y(t0)a(t_0) = \frac{\partial L}{\partial y(t_0)}. This problem has a closed form solution:

dadt=−a∂f∂y\frac{da}{dt} = -a \frac{\partial f}{\partial y}
3.1 Proof
  • For small ϵ\epsilon, we havey(t+ϵ)≈y(t)+ϵf(t,y,p)⇒∂y(t+ϵ)∂y(t)=I+ϵ∂f(t+ϵ,y,p)∂y(t)\begin{align} y(t + \epsilon) &\approx y(t) + \epsilon f(t, y, p) \\ \Rightarrow \frac{\partial y(t + \epsilon)}{\partial y(t)} &= I + \epsilon \frac{\partial f(t + \epsilon, y, p)}{\partial y(t)} \end{align}
  • Note that in the equation above, pp is the parameter and II is the identity matrix.

Applying chain rule, we have:

∂L∂y(t)⏟a(t)=∂L∂y(t+ϵ)⏟a(t+ϵ)∂y(t+ϵ)∂y(t)⇒a(t)≈a(t+ϵ)(I+ϵ∂f∂y(t))⇒a(t)−a(t+ϵ)ϵ≈a(t+ϵ)∂f∂y(t)\begin{align} \underbrace{\frac{\partial L}{\partial y(t)}}_{\textcolor{0f766e}{a(t)}} &= \underbrace{\frac{\partial L}{\partial y(t + \epsilon)}}_{\textcolor{0f766e}{a(t + \epsilon)}} \frac{\partial y(t + \epsilon)}{\partial y(t)} \\ \Rightarrow a(t) &\approx a(t + \epsilon) \left(I + \epsilon \frac{\partial f}{\partial y(t)}\right) \\ \Rightarrow \frac{a(t) - a(t+\epsilon)}{\epsilon} &\approx a(t + \epsilon) \frac{\partial f}{\partial y(t)} \end{align}

Taking the limit ϵ→0\epsilon \to 0, we get the adjoint equation:

da(t)dt=−a(t)∂f(t,y,p)∂y\frac{da(t)}{dt} = -a(t) \frac{\partial f(t,y,p)}{\partial y}

This is an ODE for a(t)a(t) that can be solved backwards in time from a(t1)=∂L∂y(t1)a(t_1) = \frac{\partial L}{\partial y(t_1)} to obtain a(t0)=∂L∂y(t0)a(t_0) = \frac{\partial L}{\partial y(t_0)}.

Once we have the gradient w.r.t y0y_0, we can update it using gradient descent and repeat the process.


4. Optimizing y0y_0

Let's start with the original equation: dydt=f(y)=−cy=−0.5y\frac{dy}{dt} = f(y) = -cy = -0.5y The state adjoint equation is given by: dadt=−a∂f∂y=0.5a\frac{da}{dt} = -a \frac{\partial f}{\partial y} = 0.5a

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 y0=1y_0 = 1 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 y at t1t_1: 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 12(y1−target)2\frac{1}{2} (y_1 - \text{target})^2 so that dLdy1=y1−target\frac{dL}{dy_1} = y_1 - \text{target}. Now it is time to set up the adjoint equation and run it backwards in time (from t1t_1 to t0t_0):

  • Compute the initial adjoint a1=dLdy1=y1−targeta_1 = \frac{dL}{dy_1} = y_1 - \text{target}:
a1 = y1 - target
print(a1)
  • Solve the backward adjoint equation, from t1t_1 to t0t_0:
adj = solve(adjoint, t1, a1, t0)
adj_line = draw(adj, orange)
  • Evaluate the adjoint at t0t_0. This is dLdy0\frac{dL}{dy_0}:
grad = at(adj, t0)
print(grad)
  • Take a gradient descent step with a learning rate of 11. 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 y1y_1
  • Calculate a(t1)=∂L∂y(t1)a(t_1) = \frac{\partial L}{\partial y(t_1)}
  • Run a backward trajectory (orange) to calculate ∂L∂y(t0)\frac{\partial L}{\partial y(t_0)}

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 dydt=f(t,y,p)\frac{dy}{dt} = f(t,y,p), the adjoint equation is quite similar to that for y:

dapdt=−a∂f∂p\frac{da_p}{dt} = -a\frac{\partial f}{\partial p}
  • apa_p: the parameter adjoint, i.e. dLdp\frac{dL}{dp}, representing how sensitively the loss changes with respect to the parameter pp.
  • aa: the state adjoint, i.e. dLdy\frac{dL}{dy}, representing how sensitively the loss changes with respect to the state yy.
5.1 Proof
  • Define an augmented variable z=[yp]z = \begin{bmatrix} y \\ p \end{bmatrix} that stacks the state with the parameter. Since the parameter is constant in time (dpdt=0\frac{dp}{dt} = 0), its dynamics are

    dzdt=[f(t,y,p)0]:=F(t,z)\frac{dz}{dt} = \begin{bmatrix} f(t,y,p) \\ 0 \end{bmatrix} := F(t,z)
  • For small ϵ\epsilon, exactly as before:

    z(t+ϵ)≈z(t)+ϵF(t+ϵ,z)⇒∂z(t+ϵ)∂z(t)=I+ϵ∂F∂z\begin{align} z(t + \epsilon) &\approx z(t) + \epsilon F(t + \epsilon, z) \\ \Rightarrow \frac{\partial z(t + \epsilon)}{\partial z(t)} &= I + \epsilon \frac{\partial F}{\partial z} \end{align}
  • Define the augmented adjoint az(t):=∂L∂z(t)=[a(t)ap(t)]a_z(t) := \frac{\partial L}{\partial z(t)} = \begin{bmatrix} a(t) & a_p(t) \end{bmatrix}, where a(t)=∂L∂y(t)a(t) = \frac{\partial L}{\partial y(t)} and ap(t)=∂L∂p(t)a_p(t) = \frac{\partial L}{\partial p(t)}. Applying the chain rule identically to the earlier argument gives the augmented adjoint equation:

    dazdt=−az∂F∂z\frac{da_z}{dt} = -a_z \frac{\partial F}{\partial z}
  • The Jacobian of FF is block-structured because the parameter rows of FF are zero:

    ∂F∂z=[∂f∂y∂f∂p00]\frac{\partial F}{\partial z} = \begin{bmatrix} \frac{\partial f}{\partial y} & \frac{\partial f}{\partial p} \\ 0 & 0 \end{bmatrix}
  • Substituting and expanding the two blocks:

    ddt[aap]=−[aap][∂f∂y∂f∂p00]=−[a∂f∂ya∂f∂p]\frac{d}{dt}\begin{bmatrix} a & a_p \end{bmatrix} = -\begin{bmatrix} a & a_p \end{bmatrix}\begin{bmatrix} \frac{\partial f}{\partial y} & \frac{\partial f}{\partial p} \\ 0 & 0 \end{bmatrix} = -\begin{bmatrix} a\frac{\partial f}{\partial y} & a\frac{\partial f}{\partial p} \end{bmatrix}

The first block recovers the state adjoint equation dadt=−a∂f∂y\frac{da}{dt} = -a\frac{\partial f}{\partial y} from before, while the second block gives the parameter adjoint equation:

dapdt=−a ∂f∂p\frac{da_p}{dt} = -a\,\frac{\partial f}{\partial p}
  • Since the loss has no explicit dependence on the parameter at the final time (it only enters through the trajectory), the boundary condition is ap(t1)=∂L∂p(t1)=0a_p(t_1) = \frac{\partial L}{\partial p(t_1)} = 0. Integrating backwards from t1t_1 to t0t_0 accumulates the parameter gradient:∂L∂p=ap(t0)=−∫t1t0a(t) ∂f∂p dt\frac{\partial L}{\partial p} = a_p(t_0) = - \int_{t_1}^{t_0} a(t)\,\frac{\partial f}{\partial p}\, dt

Coming back to the original ODE dydt=f(t,y,c)=−cy\frac{dy}{dt} = f(t,y,c) = -cy, the parameter adjoint equation is:

dapdt=−a∂f∂c=ay\frac{da_p}{dt} = -a\frac{\partial f}{\partial c} = ay

To solve this equation, for each time step tt, we will need the values a(t)a(t) and y(t)y(t).

  • Sampling y(t)y(t): these values can be sampled from the forward trajectory. This will be our first (and only) forward trajectory per iteration.
  • Sampling a(t)a(t): we still need to run the backward adjoint equation to get these values. This will be our first backward trajectory per iteration.
  • Sampling ap(t)a_p(t): now that we have the trajectories to sample a(t) and y(t), we can run the backward trajectory to get ap(t0)a_p(t_0). 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): aT=∂L∂yT=yT−targeta_T = \frac{\partial L}{\partial y_T} = y_T - \text{target}
y1 = at(fwd, t1)
end = pie.point(t1, y1, fill=blue)
a1 = y1 - target
print(a1)
  • Run the first backward ODE, the state adjoint dadt=ca\frac{da}{dt} = ca:
adj = solve(adjoint, t1, a1, t0)
adj_line = draw(adj, orange)
  • Run the second backward ODE, the parameter adjoint dapdt=ay\frac{da_p}{dt} = ay, starting from ap(t1)=0a_p(t_1) = 0. At every time tt the solver asks for, the right side reads a(t)a(t) from the first backward trajectory and y(t)y(t) 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 dLdc\frac{dL}{dc}:
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 y(t1)y(t_1).
  • Calculate a(t1)=∂L∂y(t1)a(t_1) = \frac{\partial L}{\partial y(t_1)}.
  • Run a backward trajectory (orange) to calculate a(t)a(t).
  • Run another backward trajectory (green) to calculate ap(t0)a_p(t_0).

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 a(t)a(t) and ap(t)a_p(t) are calculated on the go. More on this below.


6. Optimizing start time t0t_0

Unlike y0y_0, cc or pp, the start time t0t_0 is not a quantity carried inside the trajectory; it only sets where integration begins. Its gradient turns out to be especially simple:

∂L∂t0=−a(t0) f(t0,y(t0),p)\frac{\partial L}{\partial t_0} = -a(t_0)\, f(t_0, y(t_0), p)
  • a(t0)a(t_0): the state adjoint at the start time, i.e. ∂L∂y(t0)\frac{\partial L}{\partial y(t_0)}, which we already obtain by integrating the adjoint equation backwards to t0t_0.
  • f(t0,y(t0),p)f(t_0, y(t_0), p): the value of the dynamics at the start point, i.e. the velocity dydt\frac{dy}{dt} at t0t_0.
6.1 Proof

Here we use a clever trick - perturb initial time t0t_0 to t0+ϵt_0 + \epsilon which leads to different yy values at time t0+ϵt_0 + \epsilon. From that time onwards, it can be seen as a perturbation in the "initial condition" at t0+ϵt_0 + \epsilon and the state adjoint equation can be applied to it.

  • Hold the initial value y0y_0 fixed and shift the start time by a small ϵ\epsilon. We compare two trajectories at the same later time t0+ϵt_0 + \epsilon:

    start at t0:y(t0+ϵ)≈y0+ϵ f(t0,y0,p)start at t0+ϵ:y(t0+ϵ)=y0\begin{align} \text{start at } t_0:\quad y(t_0 + \epsilon) &\approx y_0 + \epsilon\, f(t_0, y_0, p) \\ \text{start at } t_0 + \epsilon:\quad y(t_0 + \epsilon) &= y_0 \end{align}
  • Advancing the start time therefore perturbs the state at t0+ϵt_0 + \epsilon by

    Δy=y0−(y0+ϵ f(t0,y0,p))=−ϵ f(t0,y0,p)\Delta y = y_0 - \big(y_0 + \epsilon\, f(t_0, y_0, p)\big) = -\epsilon\, f(t_0, y_0, p)
  • From t0+ϵt_0 + \epsilon onward both trajectories obey the same dynamics, so the effect on the loss is captured by the state adjoint a(t0+ϵ)≈a(t0)a(t_0 + \epsilon) \approx a(t_0):

    ΔL≈a(t0+ϵ) Δy=−ϵ a(t0) f(t0,y0,p)\Delta L \approx a(t_0 + \epsilon)\, \Delta y = -\epsilon\, a(t_0)\, f(t_0, y_0, p)
  • Dividing by ϵ\epsilon and taking the limit ϵ→0\epsilon \to 0 gives the start-time gradient:

    ∂L∂t0=−a(t0) f(t0,y(t0),p)\frac{\partial L}{\partial t_0} = -a(t_0)\, f(t_0, y(t_0), p)
  • For the original ODE f(t,y,c)=−cyf(t,y,c) = -cy, this specializes to∂L∂t0=−a(t0) (−c y(t0))=c y(t0) a(t0)\frac{\partial L}{\partial t_0} = -a(t_0)\,\big(-c\,y(t_0)\big) = c\,y(t_0)\,a(t_0)

Only one backward ODE is required: once the backward state adjoint pass reaches t0t_0, the gradient is just a product of the adjoint, the parameter and the state at the start point.

This time t0t_0 is the variable being learned, with a learning rate of 3030. 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): a(T)=y(T)−targeta(T) = y(T) - \text{target}
y1 = at(fwd, t1)
end = pie.point(t1, y1, fill=blue)
a1 = y1 - target
print(a1)
  • Run the backward adjoint ODE T→t0T \to t_0:
adj = solve(adjoint, t1, a1, t0)
adj_line = draw(adj, orange)
  • Compute the gradient dL/dt0=a(t0)⋅(c⋅y0)dL/dt_0 = a(t_0)\cdot(c\cdot y_0):
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 ∂f∂y\frac{\partial f}{\partial y}, ∂f∂c\frac{\partial f}{\partial c} 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 a(t)a(t) and a second solve for the parameter accumulator ap(t)a_p(t), stack everything into a single state and solve it once, backwards in time:

ddt[yaap]=[f(t,y,p)−a ∂f∂y−a ∂f∂p]\frac{d}{dt}\begin{bmatrix} y \\ a \\ a_p \end{bmatrix} = \begin{bmatrix} f(t,y,p) \\ -a\,\frac{\partial f}{\partial y} \\ -a\,\frac{\partial f}{\partial p} \end{bmatrix}
  • 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 [y(t), a(t), ∂L/∂θ][y(t),\, a(t),\, \partial L/\partial\theta] and integrates them together. The apa_p component here is their parameter-gradient term.
7.2 Use a VJP instead of hand-derived gradients

We wrote out ∂f∂y\frac{\partial f}{\partial y} and ∂f∂p\frac{\partial f}{\partial p} by hand. This does not scale once f has many parameters (e.g. a neural net). The adjoint only ever needs the products a ∂f∂ya\,\frac{\partial f}{\partial y} and a ∂f∂pa\,\frac{\partial f}{\partial p} 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).