Type at least 2 letters. Enter opens the first result, Esc closes.

    ML for Science · Lesson 3 · Differentiable simulation

    Adjoint methods for ODEs and neural ODEs

    Advanced 100 min 5 interactive figures 3 videos Colab notebook

    Before this lesson: lesson 01 (vector–Jacobian products, the tape, fan-out) and lesson 02 (Euler and RK4 steps, stability, stiffness). Related lessons in other tracks: calculus 09 (the adjoint of an equation F(x,θ)=0F(x, \theta) = 0) and deep learning 05 (residual networks). Time: about 100 minutes, plus the notebook.


    Why this matters #

    Fitting a model that is a differential equation needs dL/dpdL/dp for one scalar loss LL and many parameters pp. This happens everywhere in ML for science:

    • Neural ODEs and continuous normalizing flows (Chen et al. 2018) are trained with the adjoint method or with backpropagation through the solver.
    • 4D-Var data assimilation (lesson 06) finds the initial state of a weather model that best fits the observations of a time window. The gradient over millions of state variables comes from the adjoint model, run backward over the window.
    • Full-waveform inversion in seismology (lesson 04) gets the gradient with respect to millions of velocity values from one forward and one adjoint wave simulation.
    • Differentiable and hybrid climate models (lesson 16) are trained through multi-step rollouts of a solver.

    The adjoint method costs about as much as one extra solve, whatever the number of parameters. The hard questions are practical: what to store, what to recompute, whether to run the state backward, and which gradient you get.

    1. Predict first #

    2. The picture #

    A model that is an ODE #

    A residual network (ResNet) updates a hidden state by adding a correction: xk+1=xk+F(xk,θk)x_{k+1} = x_k + F(x_k, \theta_k). Write the correction as h f(xk,θ)h\,f(x_k, \theta) with a step size hh and one shared set of weights. Then

    xk+1=xk+h f(xk,θ)x_{k+1} = x_k + h\,f(x_k, \theta)

    is exactly forward Euler for the ODE u′=f(u,θ)u' = f(u, \theta) (lesson 02). The layer index kk plays the role of time t=kht = kh. A neural ODE (Chen et al. 2018) takes the limit: the model is the ODE

    u′(t)=fθ(u(t),t),u(0)=input,output=u(T),\begin{gathered} u'(t) = f_\theta(u(t), t), \qquad u(0) = \text{input}, \\ \text{output} = u(T), \end{gathered}

    where fθf_\theta is a small neural network, and any ODE solver computes u(T)u(T).

    What the continuous view gives.

    • Continuous time. The state exists at every tt, so data observed at irregular times fit in directly (latent ODEs for irregular time series, Rubanova et al. 2019).
    • Choice of solver. An adaptive solver uses more steps where the dynamics are fast and fewer where they are slow, at a set tolerance.
    • Memory. The backsolve adjoint (section 3) computes gradients with memory that does not grow with the number of steps.
    • Invertibility. The flow map u(0)↦u(T)u(0) \mapsto u(T) is invertible (run the ODE backward), and the log-density changes by ddtlog⁡p(u(t))=−tr⁡(∂fθ/∂u)\frac{d}{dt}\log p(u(t)) = -\operatorname{tr}\big(\partial f_\theta/\partial u\big). This is the basis of continuous normalizing flows, and of the probability-flow ODE of diffusion models (lesson 13).

    What it costs.

    • Every forward pass is a full ODE solve. The number of right-hand-side evaluations depends on the solver and the tolerance, and it often grows during training as fθf_\theta becomes more complex.
    • If fθf_\theta learns fast-decaying modes, the ODE becomes stiff (lesson 02): explicit solvers need tiny steps.
    • Trajectories of an ODE cannot cross (section 3), so some maps cannot be represented in the original dimension. Augmented neural ODEs (Dupont et al. 2019) add extra zero dimensions to fix this.

    Training needs dL/dθdL/d\theta. The rest of the lesson answers: how do we differentiate through an ODE solve? Everything works for any parameter vector pp; for a neural ODE, p=θp = \theta.

    Quick check

    A residual block computes xk+1=xk+h f(xk,θ)x_{k+1} = x_k + h\,f(x_k, \theta). Which method for u′=f(u,θ)u' = f(u, \theta) is this?

    The adjoint as sensitivity, travelling backward #

    Simplest case. Take u′=auu' = au with a number aa, u(0)=u0u(0) = u_0, and a loss L=ℓ(u(T))L = \ell(u(T)). A small push δu\delta u given to the state at time tt grows to ea(T−t)δue^{a(T - t)}\delta u at time TT. So the sensitivity of LL to the state at time tt is

    λ(t)=∂L∂u(t)=ea(T−t) ℓ′(u(T)).\lambda(t) = \frac{\partial L}{\partial u(t)} = e^{a(T - t)}\,\ell'(u(T)).

    This number is the adjoint (or adjoint state, or costate). Read the formula from right to left: it starts at the final time, λ(T)=ℓ′(u(T))\lambda(T) = \ell'(u(T)), and it is known at earlier times only after we know what happens later. So it is computed backward in time. Differentiate in tt: λ′=−aλ\lambda' = -a\lambda. For a system u′=Auu' = Au the same argument gives λ(t)=eA⊤(T−t)λ(T)\lambda(t) = e^{A^\top(T - t)}\lambda(T): the propagator appears transposed. This is the vector–Jacobian product of the flow map (lesson 01).

    Parameters act as a forcing. A small change δp\delta p of the parameters changes the slope ff by Jp δpJ_p\,\delta p at every time, where Jp=∂f/∂pJ_p = \partial f/\partial p. The push Jp δp dtJ_p\,\delta p\,dt given during [t,t+dt][t, t + dt] is worth λ(t)⊤Jp δp dt\lambda(t)^\top J_p\,\delta p\,dt in the loss. Adding all the pushes:

    δL=(∫0TJp⊤λ dt) ⁣⊤δp,sodLdp=∫0TJp⊤λ dt.\begin{gathered} \delta L = \Big(\int_0^T J_p^\top\lambda\,dt\Big)^{\!\top}\delta p, \\ \text{so}\quad \frac{dL}{dp} = \int_0^T J_p^\top\lambda\,dt . \end{gathered}

    The discrete picture. Forward Euler is a ResNet whose layers share the weights pp. Backprop through it: λn=∂L/∂un\lambda_n = \partial L/\partial u_n is the cotangent of layer nn, and the cotangent of the shared pp is a sum over all layers (fan-out, lesson 01). As h→0h \to 0, the backward recursion becomes the adjoint ODE and the sum becomes the integral. The adjoint method is reverse mode in time.

    Interactive figure: loading…

    Drag the time slider from TT down to 0, or press Play. The blue curve is the forward solution u(t)u(t), computed first and stored. The violet curve is the adjoint λ(t)\lambda(t); it starts at λ(T)=ℓ′(u(T))\lambda(T) = \ell'(u(T)) and is filled in backward. The dashed violet curve is the running integral ∫tTJpλ ds\int_t^T J_p\lambda\,ds; at t=0t = 0 it is dL/dpdL/dp, and the readout compares it with finite differences. For the logistic model, notice that λ\lambda is smallest in the middle, where the growth is fastest: there a small push only moves the S-curve a little earlier in time, which changes u(T)u(T) very little.

    Quick check

    For the loss L=ℓ(u(T))L = \ell(u(T)), where does the adjoint λ\lambda get its condition, and in which direction is it solved?

    Three ways to get the gradient, and their cost #

    There are three standard ways to get dL/dpdL/dp through a solver. The next section derives them; the figure compares their cost first.

    Interactive figure: loading…

    Move the sliders for the number of parameters npn_p and the number of time steps NN. The upper chart is the work in units of one forward solve; the lower chart is the memory in units of one state vector (the npn_p numbers of the gradient itself are not counted). Both axes are logarithmic. Blue bars give the exact gradient of the computed loss, magenta bars an approximation. Notice that only finite differences and forward sensitivities grow with npn_p, and only the stored adjoint grows with NN. Checkpointing keeps about 2N2\sqrt N states for one extra forward pass.

    3. Definitions and main results #

    Setting #

    We solve u′=f(u,p)u' = f(u, p), u(0)=u0u(0) = u_0 for t∈[0,T]t \in [0, T], with a state u∈Rnu \in \R^n and parameters p∈Rnpp \in \R^{n_p}. The loss is L=ℓ(u(T))L = \ell(u(T)). Along the trajectory, the Jacobians Ju=∂f/∂uJ_u = \partial f/\partial u (n×nn \times n) and Jp=∂f/∂pJ_p = \partial f/\partial p (n×npn \times n_p) are evaluated at (u(t),p)(u(t), p). A time stepper uses a fixed step hh, NN steps and T=NhT = Nh.

    Three ways to get dL/dpdL/dp #

    • Central finite differences. Perturb each parameter by ±ϵ\pm\epsilon and solve again: 2np2n_p forward solves. The error is O(ϵ2)O(\epsilon^2) plus rounding noise, plus solver noise (with adaptive steps the step sequence itself changes with pp).
    • Forward sensitivities (forward mode). The matrix S(t)=∂u(t)/∂p∈Rn×npS(t) = \partial u(t)/\partial p \in \R^{n\times n_p} solves the forward sensitivity equation (tangent equation)
    S′=JuS+Jp,S(0)=0,S' = J_u S + J_p, \qquad S(0) = 0,

    and dL/dp=S(T)⊤∇ℓ(u(T))dL/dp = S(T)^\top\nabla\ell(u(T)). This is npn_p extra ODEs of size nn (one per column of SS), solved together with uu. Nothing is stored over time. Why this equation: differentiate u′=f(u,p)u' = f(u, p) with respect to pp and use the chain rule: ddt∂u∂p=Ju∂u∂p+Jp\frac{d}{dt}\frac{\partial u}{\partial p} = J_u\frac{\partial u}{\partial p} + J_p.

    • Adjoint (reverse mode). One backward solve of size nn plus an integral, for any npn_p. It needs u(t)u(t) during the backward solve.
    method work, in forward solves memory over time exact for the code?
    central differences 2np2n_p none no: O(ϵ2)O(\epsilon^2) + rounding
    forward sensitivities about 1+np1 + n_p none yes, if derived from the code's steps
    adjoint, all states stored about 2–3, independent of npn_p N+1N + 1 states yes (discrete adjoint)
    adjoint with checkpoints about 3–4 about 2N2\sqrt N states (uniform) yes (discrete adjoint)
    backsolve adjoint about 3–4 O(1)O(1) no: O(hq)O(h^q), can be unstable

    Rule of thumb: many parameters and one loss → adjoint; few parameters → forward sensitivities.

    Quick check

    A model has 10610^6 parameters and one scalar loss on the final state. About how many solves of the ODE's size does the adjoint method need for the full gradient?

    Theorem 1 (continuous adjoint) #

    Let ff and ℓ\ell be continuously differentiable, let the solution u(t)u(t) exist on [0,T][0, T], and let λ\lambda solve the adjoint ODE backward in time:

    λ′=−Ju(u(t),p)⊤λ,λ(T)=∇ℓ(u(T)).\begin{gathered} \lambda' = -J_u(u(t), p)^\top\lambda, \\ \lambda(T) = \nabla\ell(u(T)). \end{gathered}

    Then

    dLdu0=λ(0),dLdp=∫0TJp(u(t),p)⊤λ(t) dt.\begin{gathered} \frac{dL}{du_0} = \lambda(0), \\ \frac{dL}{dp} = \int_0^T J_p(u(t), p)^\top\lambda(t)\,dt. \end{gathered}

    Why this is true (intuition). λ(t)⊤δu(t)\lambda(t)^\top\delta u(t) is "the change of the loss that a perturbation δu(t)\delta u(t) at time tt will cause". If nothing pushes the trajectory between tt and TT, this number must be the same at every time. The minus sign and the transpose in the adjoint ODE are exactly what keeps it constant.

    Proof.

    1. Perturb u0u_0 by δu0\delta u_0 and pp by δp\delta p. To first order the trajectory changes by δu(t)\delta u(t), which solves the tangent equation δu′=Ju δu+Jp δp\delta u' = J_u\,\delta u + J_p\,\delta p with δu(0)=δu0\delta u(0) = \delta u_0.
    2. Differentiate the scalar λ⊤δu\lambda^\top\delta u in time, with the product rule: (λ⊤δu)′=λ′⊤δu+λ⊤δu′(\lambda^\top\delta u)' = \lambda'^\top\delta u + \lambda^\top\delta u'. Insert the adjoint ODE (λ′⊤=−λ⊤Ju\lambda'^\top = -\lambda^\top J_u) and the tangent equation: (λ⊤δu)′=−λ⊤Ju δu+λ⊤(Ju δu+Jp δp)=λ⊤Jp δp(\lambda^\top\delta u)' = -\lambda^\top J_u\,\delta u + \lambda^\top(J_u\,\delta u + J_p\,\delta p) = \lambda^\top J_p\,\delta p. The two JuJ_u terms cancel. This is why the adjoint ODE has −Ju⊤-J_u^\top.
    3. Integrate from 00 to TT: λ(T)⊤δu(T)−λ(0)⊤δu0=(∫0Tλ⊤Jp dt) δp\lambda(T)^\top\delta u(T) - \lambda(0)^\top\delta u_0 = \big(\int_0^T \lambda^\top J_p\,dt\big)\,\delta p.
    4. By the chain rule and the final condition, δL=∇ℓ(u(T))⊤δu(T)=λ(T)⊤δu(T)\delta L = \nabla\ell(u(T))^\top\delta u(T) = \lambda(T)^\top\delta u(T).
    5. So δL=λ(0)⊤δu0+(∫0TJp⊤λ dt)⊤δp\delta L = \lambda(0)^\top\delta u_0 + \big(\int_0^T J_p^\top\lambda\,dt\big)^\top\delta p for every perturbation. The coefficients of δu0\delta u_0 and δp\delta p are the two gradients. ■\blacksquare
    The same result with a Lagrange multiplier
    1. Add zero to the loss: L=ℓ(u(T))+∫0Tλ⊤(f(u,p)−u′) dt\mathcal L = \ell(u(T)) + \int_0^T \lambda^\top\big(f(u, p) - u'\big)\,dt for any λ(t)\lambda(t).
    2. Perturb: δL=∇ℓ⊤δu(T)+∫0Tλ⊤(Juδu+Jpδp−δu′) dt\delta\mathcal L = \nabla\ell^\top\delta u(T) + \int_0^T \lambda^\top\big(J_u\delta u + J_p\delta p - \delta u'\big)\,dt.
    3. Integrate −∫0Tλ⊤δu′ dt-\int_0^T\lambda^\top\delta u'\,dt by parts: it equals −λ(T)⊤δu(T)+λ(0)⊤δu(0)+∫0Tλ′⊤δu dt-\lambda(T)^\top\delta u(T) + \lambda(0)^\top\delta u(0) + \int_0^T\lambda'^\top\delta u\,dt.
    4. Collect terms: δL=(∇ℓ−λ(T))⊤δu(T)+λ(0)⊤δu0+∫0T(λ′+Ju⊤λ)⊤δu dt+∫0Tλ⊤Jp dt δp\delta\mathcal L = \big(\nabla\ell - \lambda(T)\big)^\top\delta u(T) + \lambda(0)^\top\delta u_0 + \int_0^T\big(\lambda' + J_u^\top\lambda\big)^\top\delta u\,dt + \int_0^T\lambda^\top J_p\,dt\,\delta p.
    5. Choose λ\lambda so that the terms with the unknown δu(t)\delta u(t) and δu(T)\delta u(T) vanish: λ′=−Ju⊤λ\lambda' = -J_u^\top\lambda and λ(T)=∇ℓ(u(T))\lambda(T) = \nabla\ell(u(T)). What remains is Theorem 1. ■\blacksquare

    Extensions (each follows from the same proof).

    • A running cost L=∫0Tg(u,p) dtL = \int_0^T g(u, p)\,dt adds a source: λ′=−Ju⊤λ−∇ug\lambda' = -J_u^\top\lambda - \nabla_u g, and dL/dpdL/dp gets the extra term ∫0T∇pg dt\int_0^T\nabla_p g\,dt.
    • Losses at several times t1<⋯<tKt_1 < \dots < t_K, as in fitting observed data: in the backward sweep, add ∇ℓk(u(tk))\nabla\ell_k(u(t_k)) to λ\lambda each time you pass tkt_k. Between the observations, λ\lambda follows the adjoint ODE.
    • An initial state that depends on pp adds (∂u0/∂p)⊤λ(0)(\partial u_0/\partial p)^\top\lambda(0) to dL/dpdL/dp.
    • The augmented backward system. Let μ′=−Jp⊤λ\mu' = -J_p^\top\lambda with μ(T)=0\mu(T) = 0; then μ(0)=∫0TJp⊤λ dt=dL/dp\mu(0) = \int_0^T J_p^\top\lambda\,dt = dL/dp. Solving [u,λ,μ][u, \lambda, \mu] together from TT back to 00, starting from [u(T),∇ℓ(u(T)),0][u(T), \nabla\ell(u(T)), 0], re-creates u(t)u(t) backward instead of storing it. This is the backsolve adjoint of the neural ODE paper (Chen et al. 2018; odeint_adjoint in torchdiffeq).

    Stability of the adjoint. In reversed time s=T−ts = T - t the adjoint reads dλ/ds=Ju⊤λd\lambda/ds = J_u^\top\lambda, with the same eigenvalues as JuJ_u. So, for a constant JuJ_u, if the forward problem damps a mode, the adjoint run backward damps it too: the adjoint equation is stable backward. The re-created state is different. In reversed time it solves du/ds=−fdu/ds = -f, so every mode that decays forward grows backward.

    Theorem 2 (discrete adjoint of a one-step method) #

    A one-step method computes un+1=Φ(un,p)u_{n+1} = \Phi(u_n, p) for n=0,…,N−1n = 0, \dots, N - 1, and the code's loss is Lh=ℓ(uN)L_h = \ell(u_N). Let Φu\Phi_u and Φp\Phi_p be the Jacobians of one step, and

    λN=∇ℓ(uN),λn=Φu(un,p)⊤λn+1(n=N−1,…,0).\begin{gathered} \lambda_N = \nabla\ell(u_N), \\ \lambda_n = \Phi_u(u_n, p)^\top\lambda_{n+1} \quad (n = N - 1, \dots, 0). \end{gathered}

    Then

    dLhdu0=λ0,dLhdp=∑n=0N−1Φp(un,p)⊤λn+1.\begin{gathered} \frac{dL_h}{du_0} = \lambda_0, \\ \frac{dL_h}{dp} = \sum_{n=0}^{N-1}\Phi_p(u_n, p)^\top\lambda_{n+1}. \end{gathered}

    Proof (the same argument as Theorem 1, in discrete time).

    1. Tangent recursion: δun+1=Φu δun+Φp δp\delta u_{n+1} = \Phi_u\,\delta u_n + \Phi_p\,\delta p.
    2. Multiply by λn+1⊤\lambda_{n+1}^\top and use λn⊤=λn+1⊤Φu\lambda_n^\top = \lambda_{n+1}^\top\Phi_u: λn+1⊤δun+1−λn⊤δun=λn+1⊤Φp δp\lambda_{n+1}^\top\delta u_{n+1} - \lambda_n^\top\delta u_n = \lambda_{n+1}^\top\Phi_p\,\delta p.
    3. Sum over nn. The left side telescopes: λN⊤δuN−λ0⊤δu0=∑nλn+1⊤Φp δp\lambda_N^\top\delta u_N - \lambda_0^\top\delta u_0 = \sum_n\lambda_{n+1}^\top\Phi_p\,\delta p.
    4. δLh=λN⊤δuN\delta L_h = \lambda_N^\top\delta u_N; read off the two gradients. ■\blacksquare

    This is the tape sweep of lesson 01 with one node per time step, and it is what automatic differentiation computes through the solver. It is the exact gradient of the loss that the code computes, up to rounding. It stores the N+1N + 1 states (or recomputes them).

    Forward Euler. Φ(u,p)=u+hf(u,p)\Phi(u, p) = u + hf(u, p), so Φu=I+hJu(un)\Phi_u = I + hJ_u(u_n) and Φp=hJp(un)\Phi_p = hJ_p(u_n):

    λn=λn+1+h Ju(un)⊤λn+1,dLhdp=h∑n=0N−1Jp(un)⊤λn+1.\begin{gathered} \lambda_n = \lambda_{n+1} + h\,J_u(u_n)^\top\lambda_{n+1}, \\ \frac{dL_h}{dp} = h\sum_{n=0}^{N-1}J_p(u_n)^\top\lambda_{n+1}. \end{gathered}

    Compare explicit Euler applied backward to the adjoint ODE: λn=λn+1+h Ju(un+1)⊤λn+1\lambda_n = \lambda_{n+1} + h\,J_u(u_{n+1})^\top\lambda_{n+1}. The Jacobian sits at the other end of the step. Both are first-order approximations of the same ODE, but they give different numbers.

    Quick check

    You write forward Euler in PyTorch and call loss.backward(). What is the gradient you get?

    Discrete adjoint of an explicit Runge–Kutta step

    An explicit RK method with ss stages, coefficients aija_{ij} (j<ij < i) and weights bib_i takes one step by

    Ui=un+h∑j<iaijkj,ki=f(Ui,p),un+1=un+h∑i=1sbiki.\begin{gathered} U_i = u_n + h\sum_{j<i}a_{ij}k_j, \qquad k_i = f(U_i, p), \\ u_{n+1} = u_n + h\sum_{i=1}^s b_ik_i . \end{gathered}

    Given λn+1\lambda_{n+1}, go through the stages in reverse order, i=s,…,1i = s, \dots, 1:

    kˉi=h(biλn+1+∑j>iajiUˉj),Uˉi=Ju(Ui)⊤kˉi,pˉ+=Jp(Ui)⊤kˉi.\begin{gathered} \bar k_i = h\Big(b_i\lambda_{n+1} + \sum_{j>i}a_{ji}\bar U_j\Big), \\ \bar U_i = J_u(U_i)^\top\bar k_i, \qquad \bar p \mathrel{+}= J_p(U_i)^\top\bar k_i . \end{gathered}

    Then λn=λn+1+∑i=1sUˉi\lambda_n = \lambda_{n+1} + \sum_{i=1}^s\bar U_i, and pˉ\bar p is this step's term of dLh/dpdL_h/dp.

    Proof (reverse mode through the graph of one step).

    1. un+1u_{n+1} uses unu_n with weight II and each kik_i with weight hbihb_i. This sends λn+1\lambda_{n+1} to uˉn\bar u_n and hbiλn+1hb_i\lambda_{n+1} to kˉi\bar k_i.
    2. kik_i is also used by every later stage point UjU_j (j>ij > i), with weight hajiha_{ji}: fan-out adds hajiUˉjha_{ji}\bar U_j to kˉi\bar k_i. Going through the stages in reverse order makes every Uˉj\bar U_j final before it is needed.
    3. ki=f(Ui,p)k_i = f(U_i, p): its VJP sends Ju(Ui)⊤kˉiJ_u(U_i)^\top\bar k_i to UiU_i and Jp(Ui)⊤kˉiJ_p(U_i)^\top\bar k_i to pp.
    4. Every UiU_i uses unu_n with weight II, so uˉn\bar u_n also collects ∑iUˉi\sum_i\bar U_i. ■\blacksquare

    The Jacobians are taken at the stage points UiU_i (only U1=unU_1 = u_n), not at un+1u_{n+1}. Cost per step: ss products with Ju⊤J_u^\top and ss with Jp⊤J_p^\top.

    What this scheme is. Write kˉi=hbiΛi\bar k_i = hb_i\Lambda_i (when bi≠0b_i \ne 0). The recursion becomes Λi=λn+1+h∑j>i(bjaji/bi) Ju(Uj)⊤Λj\Lambda_i = \lambda_{n+1} + h\sum_{j>i}(b_ja_{ji}/b_i)\,J_u(U_j)^\top\Lambda_j and λn=λn+1+h∑ibiJu(Ui)⊤Λi\lambda_n = \lambda_{n+1} + h\sum_i b_iJ_u(U_i)^\top\Lambda_i. This is a Runge–Kutta step for the adjoint ODE, backward from tn+1t_{n+1} to tnt_n, with weights bib_i and coefficients bjaji/bib_ja_{ji}/b_i (Hager 2000), but with the Jacobians frozen at the forward stage points UiU_i. For classic RK4 the new coefficients, read in reverse stage order, are again the RK4 coefficients (b2a21/b1=1b_2a_{21}/b_1 = 1, b3a32/b2=12b_3a_{32}/b_2 = \tfrac12, b4a43/b3=12b_4a_{43}/b_3 = \tfrac12). For many other methods they are not. When ff is smooth, Lh=L+C(p) hq+O(hq+1)L_h = L + C(p)\,h^q + O(h^{q+1}) for a method of order qq, and differentiating this expansion shows that dLh/dpdL_h/dp converges to the exact dL/dpdL/dp at the same order qq.

    When do the continuous and the discrete adjoints differ? #

    The two choices have names. Discretize-then-optimize is the discrete adjoint: differentiate the code. Optimize-then-discretize is the continuous adjoint: derive the adjoint ODE, then solve it numerically.

    • They agree in one clean case: a linear autonomous system u′=Auu' = Au, an explicit RK method, and the gradient with respect to u0u_0. One step is un+1=R(hA)unu_{n+1} = R(hA)u_n with a polynomial RR (the stability polynomial of lesson 02), so the discrete adjoint is λn=R(hA)⊤λn+1\lambda_n = R(hA)^\top\lambda_{n+1}. The same method on λ′=−A⊤λ\lambda' = -A^\top\lambda backward with step hh gives R(hA⊤)λn+1R(hA^\top)\lambda_{n+1}, and R(hA⊤)=R(hA)⊤R(hA^\top) = R(hA)^\top. The parameter gradient still differs, because JpJ_p depends on uu and the two methods evaluate it at different points.
    • Finite step. In general the two differ by O(hq)O(h^q). The discrete adjoint is exact for LhL_h. The continuous adjoint approximates dL/dpdL/dp, but it is not the gradient of any loss that the code computes. Near an optimum this mismatch can be as large as the gradient itself, and line searches can fail.
    • Adaptive steps. The backward solve chooses its own steps, so the two time grids differ.
    • Non-smooth ff (events, if branches, clipping). The discrete adjoint differentiates exactly the code that runs.
    • Backward re-creation of uu (backsolve) can blow up; see the second figure below.
    • Chaos is not a difference between the two. For a chaotic system both gradients grow exponentially backward in time (like e0.906 (T−t)e^{0.906\,(T - t)} for Lorenz-63, whose largest Lyapunov exponent is about 0.906; lesson 05). Gradients over long windows are huge and not useful for either method.
    Interactive figure: loading…

    Switch between Euler and RK4, and move the step size hh. The problem is u′=−puu' = -pu, u0=1u_0 = 1, p=1p = 1, T=1.5T = 1.5, ℓ=12u(T)2\ell = \tfrac12u(T)^2, with exact gradient dL/dp=−Te−2pTdL/dp = -Te^{-2pT}. The orange line is the error of the discrete adjoint, the violet line the error of the continuous adjoint (computed by backsolve with the same method), and the magenta line their difference. All three have slope 1 for Euler and slope 4 for RK4: both gradients converge, but the gap between them never reaches zero.

    Why backsolve fails for damped modes. Re-creating uu backward means solving du/ds=−fdu/ds = -f in reversed time. A mode that decays like e−kte^{-kt} forward grows like ek(T−t)e^{k(T - t)} backward. Every rounding error in that mode at time TT (size about 10−1610^{-16} relative to the state) is multiplied by ekTe^{kT} when it reaches t=0t = 0. With diffusion, damping, seismic attenuation or stiff chemistry, the re-created uu, and every Jacobian evaluated on it, is wrong. The adjoint λ\lambda itself stays stable.

    Interactive figure: loading…

    Move the decay rate kk of the fast mode. The system is u′=Auu' = Au with AA having eigenvalues −1-1 and −k-k (in rotated axes). RK4 with h=10−3h = 10^{-3} solves it forward to T=1T = 1, then backward from the computed u(T)u(T). The magenta curve is the error of the re-created state ∣u^(t)−u(t)∣|\hat u(t) - u(t)|; the dashed grey line is 10−16ek(T−t)10^{-16}e^{k(T - t)}. The violet curve is the size of the adjoint ∣λ(t)∣|\lambda(t)|, solved backward on the stored states: it stays bounded. Notice that for kk of about 35 and more the re-created u^(0)\hat u(0) is useless, while the forward solution was accurate.

    Checkpointing: memory against recomputation #

    The discrete adjoint needs the states unu_n in reverse order. For NN steps:

    strategy states in memory extra forward steps
    store every state N+1N + 1 0
    store only u0u_0; recompute each unu_n from it 1 about N2/2N^2/2
    uniform: a checkpoint every k=N/Ck = N/C steps about C+N/CC + N/C, smallest (≈2N\approx 2\sqrt N) at C=NC = \sqrt N fewer than NN (at most one more forward pass)
    recursive (binomial, Revolve) O(log⁡N)O(\log N) O(Nlog⁡N)O(N\log N)
    backsolve (continuous adjoint) O(1)O(1) one backward re-integration of uu; not exact

    Uniform checkpointing. In the forward pass, store only u0,uk,u2k,…u_0, u_k, u_{2k}, \dots. In the backward pass, take the segments from last to first: recompute the k−1k - 1 missing states of the segment from its checkpoint, run the adjoint through them, and free them. The result equals full storage to rounding, because the recomputed states are the same numbers.

    Why N\sqrt N. During the backward pass of one segment we hold at most CC checkpoints plus the k=N/Ck = N/C states of the segment. The function C+N/CC + N/C is smallest where its derivative 1−N/C21 - N/C^2 is zero, at C=NC = \sqrt N, and there it equals 2N2\sqrt N.

    Interactive figure: loading…

    Choose the number of steps NN and the number of checkpoints CC, then press Backward step. Blue boxes are checkpoints stored in the forward pass; orange boxes are states recomputed for the current segment; the violet outline is the step whose adjoint is being computed. The chart shows the peak memory and the number of recomputed steps for every CC. Notice the minimum of the memory curve near C=NC = \sqrt N.

    Binomial checkpointing and reversible solvers

    Binomial checkpointing (Griewank 1992; the Revolve algorithm of Griewank and Walther 2000) places checkpoints recursively. With cc checkpoints and at most rr recomputations of any step, it can reverse (c+rc)\binom{c + r}{c} steps, and this number is the largest possible. So for a fixed memory the number of reversible steps grows very fast with rr. Diffrax's default RecursiveCheckpointAdjoint is a discrete adjoint with this kind of checkpointing.

    Reversible solvers are time steppers whose step can be inverted exactly in floating point (for example the reversible Heun method used by Kidger et al. 2021 for neural SDEs). They give the O(1)O(1) memory of backsolve without its instability, because the backward pass recovers exactly the forward states.

    Activation checkpointing in deep learning (torch.utils.checkpoint, jax.checkpoint) is the same idea for the layers of a network.

    Neural ODEs: trajectories cannot cross #

    Let fθf_\theta be Lipschitz in uu. Then the solution through any starting point is unique (Picard–Lindelöf). Two consequences:

    • Trajectories never cross. If two solutions met at some time, the unique solution through that meeting point would have to be both of them.
    • In one dimension the flow map is increasing. If a<ba < b, then ua(t)<ub(t)u_a(t) < u_b(t) for all tt: the two solutions are continuous, start in this order, and cannot pass through each other. So the map u(0)↦u(T)u(0) \mapsto u(T) is a strictly increasing function. In general dimension the flow map is a homeomorphism (continuous, invertible, with a continuous inverse).

    This is why a 1D neural ODE cannot learn u↦−uu \mapsto -u, and why augmented neural ODEs add dimensions. A ResNet with finite steps can cross, because each Euler step is a jump, not a continuous path.

    Answers to section 1. (1) Central differences: 2np=2⋅1052n_p = 2 \cdot 10^5 solves. Forward sensitivities: np=105n_p = 10^5 tangent ODEs of size 10610^6, so about 10510^5 solves of work and 101110^{11} numbers in SS. Adjoint: one forward and one backward solve (about 2–3 solves of work) for any npn_p, plus memory for the trajectory or for checkpoints. (2) No. Automatic differentiation gives the discrete adjoint, the exact gradient of the computed loss. The RK4 adjoint ODE gives another O(h4)O(h^4) approximation of the exact gradient. For RK4 the two schemes have the same coefficients, but they evaluate JuJ_u and JpJ_p at different states. They agree to rounding only in special cases, such as dL/du0dL/du_0 for a linear autonomous system. (3) No. The flow map of a 1D ODE is strictly increasing, and u↦−uu \mapsto -u is decreasing.

    4. Worked example #

    Take u′=−puu' = -pu with u0=1u_0 = 1, p=1p = 1, T=1.5T = 1.5, and the loss ℓ(u)=12u2\ell(u) = \tfrac12u^2. Here f=−puf = -pu, so Ju=−pJ_u = -p and Jp=−uJ_p = -u. Use forward Euler with h=0.5h = 0.5, so N=3N = 3 and each step multiplies the state by 1−hp=121 - hp = \tfrac12.

    Step 1: forward pass. u0=1u_0 = 1, u1=12u_1 = \tfrac12, u2=14u_2 = \tfrac14, u3=18u_3 = \tfrac18. The computed loss is Lh=12u32=1128=0.0078125L_h = \tfrac12u_3^2 = \tfrac{1}{128} = 0.0078125.

    Step 2: discrete adjoint (Theorem 2 with Φu=1−hp=12\Phi_u = 1 - hp = \tfrac12 and Φp(un)=−hun\Phi_p(u_n) = -hu_n).

    • Start: λ3=ℓ′(u3)=u3=18\lambda_3 = \ell'(u_3) = u_3 = \tfrac18.
    • Backward: λ2=12⋅18=116\lambda_2 = \tfrac12\cdot\tfrac18 = \tfrac{1}{16}, λ1=132\lambda_1 = \tfrac{1}{32}, λ0=164\lambda_0 = \tfrac{1}{64}.
    • Parameter gradient: dLh/dp=∑n(−hun)λn+1=−12(1⋅132+12⋅116+14⋅18)=−12⋅332=−364=−0.046875dL_h/dp = \sum_n(-hu_n)\lambda_{n+1} = -\tfrac12\big(1\cdot\tfrac{1}{32} + \tfrac12\cdot\tfrac{1}{16} + \tfrac14\cdot\tfrac18\big) = -\tfrac12\cdot\tfrac{3}{32} = -\tfrac{3}{64} = -0.046875. The shared pp collects one term per step (fan-out); here each term is −164-\tfrac{1}{64}.

    Step 3: check by hand. u3=(1−p/2)3u0u_3 = (1 - p/2)^3u_0, so Lh(p,u0)=12(1−p/2)6u02L_h(p, u_0) = \tfrac12(1 - p/2)^6u_0^2. Then dLh/dp=−32(1−p/2)5u02=−32⋅132=−364dL_h/dp = -\tfrac32(1 - p/2)^5u_0^2 = -\tfrac32\cdot\tfrac{1}{32} = -\tfrac{3}{64} ✓, and dLh/du0=(1−p/2)6u0=164=λ0dL_h/du_0 = (1 - p/2)^6u_0 = \tfrac{1}{64} = \lambda_0 ✓. Exact for the code.

    Step 4: forward sensitivity gives the same number. The discrete tangent recursion is Sn+1=(1−hp)Sn−hunS_{n+1} = (1 - hp)S_n - hu_n with S0=0S_0 = 0: S1=−12S_1 = -\tfrac12, S2=12(−12)−12⋅12=−12S_2 = \tfrac12(-\tfrac12) - \tfrac12\cdot\tfrac12 = -\tfrac12, S3=12(−12)−12⋅14=−38S_3 = \tfrac12(-\tfrac12) - \tfrac12\cdot\tfrac14 = -\tfrac38. Then dLh/dp=S3 u3=−38⋅18=−364dL_h/dp = S_3\,u_3 = -\tfrac38\cdot\tfrac18 = -\tfrac{3}{64} ✓. Forward and reverse mode of the same code give the same gradient.

    Step 5: backsolve (the augmented system [u,λ,μ][u, \lambda, \mu] integrated backward with Euler, step −h-h, using the values at the right end of each step).

    • uu: u^n=u^n+1−hf(u^n+1)=(1+hp) u^n+1\hat u_n = \hat u_{n+1} - h f(\hat u_{n+1}) = (1 + hp)\,\hat u_{n+1}, so u^=18→316→932→2764\hat u = \tfrac18 \to \tfrac{3}{16} \to \tfrac{9}{32} \to \tfrac{27}{64}. The re-created u^0≈0.42\hat u_0 \approx 0.42, not u0=1u_0 = 1.
    • λ\lambda: λn=λn+1−h p λn+1=(1−hp) λn+1\lambda_n = \lambda_{n+1} - h\,p\,\lambda_{n+1} = (1 - hp)\,\lambda_{n+1}: the same values as the discrete adjoint (a linear autonomous system), so dL/du0≈164dL/du_0 \approx \tfrac{1}{64} again.
    • μ\mu: μ′=−Jp⊤λ=uλ\mu' = -J_p^\top\lambda = u\lambda, so μn=μn+1−h u^n+1λn+1\mu_n = \mu_{n+1} - h\,\hat u_{n+1}\lambda_{n+1}. This gives μ0=−12(18⋅18+316⋅116+932⋅132)=−372048≈−0.01807\mu_0 = -\tfrac12\big(\tfrac18\cdot\tfrac18 + \tfrac{3}{16}\cdot\tfrac{1}{16} + \tfrac{9}{32}\cdot\tfrac{1}{32}\big) = -\tfrac{37}{2048} \approx -0.01807.

    Step 6: exact gradient of the continuous problem. u(t)=e−ptu(t) = e^{-pt}, λ(t)=ep(t−T)u(T)=ep(t−2T)\lambda(t) = e^{p(t - T)}u(T) = e^{p(t - 2T)}, and dL/dp=∫0T(−u)λ dt=−∫0Te−2pT dt=−Te−2pT≈−0.07468dL/dp = \int_0^T(-u)\lambda\,dt = -\int_0^T e^{-2pT}\,dt = -Te^{-2pT} \approx -0.07468; dL/du0=e−2pT≈0.04979dL/du_0 = e^{-2pT} \approx 0.04979.

    dL/dpdL/dp dL/du0dL/du_0
    discrete adjoint (exact for the code) −0.046875-0.046875 0.0156250.015625
    backsolve with Euler −0.018066-0.018066 0.0156250.015625
    exact, continuous problem −0.074681-0.074681 0.0497870.049787

    The table shows three things. The discrete adjoint is the gradient of what the code computes. The backsolve value is a third number, partly because the re-created u^\hat u is far from the true uu. And with h=0.5h = 0.5 neither is close to the exact gradient; both converge as h→0h \to 0 (the third figure).

    Quick check

    Same problem (u′=−puu' = -pu, u0=1u_0 = 1, p=1p = 1, ℓ=12u2\ell = \tfrac12u^2, Euler with h=0.5h = 0.5), but with only N=2N = 2 steps. What is dLh/du0dL_h/du_0?

    5. In code #

    NumPy checks the worked example, the convergence of both gradients, and the backsolve failure. The notebook has these cells and the exercises.

    import numpy as np
    
    # Worked example: u' = -p u, l = u(T)^2 / 2, forward Euler with N steps
    def euler_loss(p, u0, T=1.5, N=3):
        h, u = T / N, u0
        for _ in range(N):
            u = u + h * (-p * u)
        return 0.5 * u**2
    
    def discrete_adjoint(p, u0, T=1.5, N=3):
        h = T / N
        u = [u0]
        for _ in range(N):                      # forward pass: store every state
            u.append(u[-1] + h * (-p * u[-1]))
        lam, dp = u[N], 0.0                     # lambda_N = dl/du_N = u_N
        for n in range(N - 1, -1, -1):          # backward pass
            dp += (-h * u[n]) * lam             # Phi_p(u_n)^T lambda_{n+1}
            lam = (1 - h * p) * lam             # Phi_u^T lambda_{n+1}
        return dp, lam                          # dL_h/dp, dL_h/du0
    
    print(euler_loss(1.0, 1.0))                 # 0.0078125
    print(discrete_adjoint(1.0, 1.0))           # (-0.046875, 0.015625)
    eps = 1e-6
    fd = (euler_loss(1 + eps, 1.0) - euler_loss(1 - eps, 1.0)) / (2 * eps)
    print(f"{fd:.9f}")                          # -0.046875000
    
    # Both gradients converge to the exact one at order 1, but they stay different numbers
    T, p = 1.5, 1.0
    exact = -T * np.exp(-2 * p * T)
    for N in [3, 12, 48, 192]:
        h = T / N
        uN = (1 - h * p) ** N
        disc = -T * (1 - h * p) ** (2 * N - 1)                               # discrete adjoint
        back = -h * uN**2 * sum((1 - (h * p) ** 2) ** j for j in range(N))   # Euler backsolve
        print(N, f"{abs(disc - exact):.1e} {abs(back - exact):.1e} {abs(disc - back):.1e}")
    # 3 2.8e-02 5.7e-02 2.9e-02
    # 12 5.1e-03 1.9e-02 1.4e-02
    # 48 1.2e-03 5.1e-03 3.9e-03
    # 192 2.9e-04 1.3e-03 1.0e-03
    
    # Backsolve: re-integrating u backward is unstable for a fast-decaying mode
    def rk4(f, y, h):
        k1 = f(y); k2 = f(y + h / 2 * k1); k3 = f(y + h / 2 * k2); k4 = f(y + h * k3)
        return y + h / 6 * (k1 + 2 * k2 + 2 * k3 + k4)
    
    th = 0.3
    Q = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]])
    A = Q @ np.diag([-1.0, -40.0]) @ Q.T        # a slow mode and a fast-decaying mode
    u = np.array([1.0, 1.0])
    for _ in range(1000):
        u = rk4(lambda y: A @ y, u, 1e-3)       # forward to T = 1: accurate
    for _ in range(1000):
        u = rk4(lambda y: A @ y, u, -1e-3)      # backward from T to 0
    print(u)                                    # about [ 3.2 -6.2], not [1. 1.]
    

    The two closed forms in the middle part repeat section 4 with NN steps: the discrete adjoint gives dLh/dp=−T(1−hp)2N−1dL_h/dp = -T(1 - hp)^{2N - 1}, and the Euler backsolve gives μ0=−h uN2∑j=0N−1(1−h2p2)j\mu_0 = -h\,u_N^2\sum_{j=0}^{N-1}(1 - h^2p^2)^j, because u^nλn=uN2(1−h2p2)N−n\hat u_n\lambda_n = u_N^2(1 - h^2p^2)^{N - n}. In the last part the forward solve is accurate. The backward solve starts from a state whose fast component is far below rounding level, and the backward flow multiplies its errors by up to e40≈2⋅1017e^{40} \approx 2\cdot10^{17}. The exact digits of the wrong answer depend on rounding, so they can differ on your machine.

    6. Where it shows up #

    • Neural ODEs and differentiable solvers. torchdiffeq's odeint_adjoint is the backsolve adjoint. Diffrax's default, RecursiveCheckpointAdjoint, is the discrete adjoint with checkpointing, and its documentation marks BacksolveAdjoint as not recommended. Kidger (2022, §5.1) recommends discretize-then-optimize first. Continuous normalizing flows (FFJORD, Grathwohl et al. 2019) and latent ODEs for irregularly sampled time series (Rubanova et al. 2019) are trained this way.
    • Weather and ocean. 4D-Var (lesson 06) minimizes a cost over the initial state of an assimilation window. At ECMWF the gradient of this cost comes from the adjoint of a linearized, lower-resolution version of the forecast model, run backward over the window. Ocean state estimation (ECCO) uses the adjoint of the MITgcm ocean model, generated from the model code by algorithmic differentiation: a discrete adjoint of the code.
    • Seismology. Adjoint tomography and full-waveform inversion (lesson 04) get the gradient of a waveform misfit from one forward and one adjoint wave simulation per earthquake, for millions of model parameters. The adjoint run needs the forward wavefield in reverse time: store it, checkpoint it, or re-create it backward. Without attenuation the wave equation is time-reversible, so the forward field can be re-created backward; with attenuation this re-creation is unstable (Komatitsch et al. 2016). This is the backsolve problem of section 3.
    • Learned and hybrid weather models. Training on multi-step rollouts, as for NeuralGCM (Kochkov et al. 2024) or the autoregressive fine-tuning of an emulator, is the discrete adjoint through the rollout. Activation memory grows with the number of steps, so each step is checkpointed. Chaos limits the useful rollout length.

    7. Common mistakes #

    • JuJ_u instead of Ju⊤J_u^\top, a wrong sign, or starting λ\lambda at t=0t = 0. The adjoint has a final condition, λ(T)=∇ℓ(u(T))\lambda(T) = \nabla\ell(u(T)), and runs backward with −Ju⊤-J_u^\top.
    • Forgetting that pp is shared by all steps. Its cotangent is a sum (or an integral) over all steps. Also forgetting the (∂u0/∂p)⊤λ(0)(\partial u_0/\partial p)^\top\lambda(0) term when the initial state depends on pp.
    • Jacobians at the wrong points in the discrete adjoint. They belong at the stage points UiU_i of the forward step. Only U1=unU_1 = u_n; the other stage points are neither unu_n nor un+1u_{n+1}.
    • Checking a continuous adjoint against finite differences of the code and calling the O(hq)O(h^q) gap a bug. Check the discrete adjoint against finite differences (to about 10−810^{-8} relative) and against automatic differentiation (to about 10−1210^{-12}). Check the continuous adjoint by its convergence order.
    • Backsolve on a dissipative or stiff problem. The re-created state is wrong, so the gradient is wrong, even though the adjoint equation is stable.
    • Finite-difference checks through an adaptive solver. The step sequence changes with pp, which adds noise. Use fixed steps for gradient checks.
    • Long chaotic windows. The exact gradient is huge and of little use for optimization; no adjoint method fixes that.

    8. Check yourself #

    1. For L=ℓ(u(T))L = \ell(u(T)), state the adjoint ODE, its condition, and the formulas for dL/du0dL/du_0 and dL/dpdL/dp. Which step of the proof forces the minus sign and the transpose?
      Answerλ′=−Ju⊤λ\lambda' = -J_u^\top\lambda with λ(T)=∇ℓ(u(T))\lambda(T) = \nabla\ell(u(T)); dL/du0=λ(0)dL/du_0 = \lambda(0) and dL/dp=∫0TJp⊤λ dtdL/dp = \int_0^T J_p^\top\lambda\,dt. In (λ⊤δu)′=λ′⊤δu+λ⊤(Juδu+Jpδp)(\lambda^\top\delta u)' = \lambda'^\top\delta u + \lambda^\top(J_u\delta u + J_p\delta p), the δu\delta u terms cancel only if λ′⊤=−λ⊤Ju\lambda'^\top = -\lambda^\top J_u, that is, λ′=−Ju⊤λ\lambda' = -J_u^\top\lambda.
    2. u′=−puu' = -pu, u0=2u_0 = 2, p=1p = 1, ℓ=12u2\ell = \tfrac12u^2, one Euler step with h=0.5h = 0.5 (T=0.5T = 0.5). Compute LhL_h, the discrete-adjoint dLh/dpdL_h/dp, the Euler backsolve value, and the exact dL/dpdL/dp.
      Answeru1=2(1−0.5)=1u_1 = 2(1 - 0.5) = 1 and Lh=0.5L_h = 0.5. Discrete: λ1=u1=1\lambda_1 = u_1 = 1, dLh/dp=−hu0λ1=−1dL_h/dp = -hu_0\lambda_1 = -1 (check: Lh=12u02(1−hp)2L_h = \tfrac12u_0^2(1 - hp)^2, derivative −hu02(1−hp)=−1-hu_0^2(1 - hp) = -1). Backsolve: μ0=−h u^1λ1=−0.5\mu_0 = -h\,\hat u_1\lambda_1 = -0.5. Exact: −Tu02e−2pT=−2e−1≈−0.736-Tu_0^2e^{-2pT} = -2e^{-1} \approx -0.736.
    3. Write the discrete adjoint of forward Euler. Where is JuJ_u evaluated, and how does that differ from explicit Euler applied backward to the adjoint ODE?
      Answerλn=λn+1+hJu(un)⊤λn+1\lambda_n = \lambda_{n+1} + hJ_u(u_n)^\top\lambda_{n+1} and dLh/dp=h∑nJp(un)⊤λn+1dL_h/dp = h\sum_n J_p(u_n)^\top\lambda_{n+1}. JuJ_u is at the start point unu_n of the forward step. Explicit Euler backward on λ′=−Ju⊤λ\lambda' = -J_u^\top\lambda uses Ju(un+1)J_u(u_{n+1}), the start of the backward step. Same order, different numbers.
    4. A rollout has N=10 000N = 10\,000 steps. With uniform checkpointing, what number of checkpoints minimizes memory, how many states are in memory, and what is the extra compute?
      AnswerMemory ≈C+N/C\approx C + N/C, smallest at C=N=100C = \sqrt N = 100: about 200200 states instead of 10 00110\,001. Each missing state is recomputed once, so the extra work is less than one forward pass (N−C=9 900N - C = 9\,900 steps).
    5. Why can backsolve fail for a discretized heat equation, although the adjoint equation itself is stable backward in time?
      AnswerRe-creating uu backward solves the backward heat equation: a Fourier mode that decays like e−νk2te^{-\nu k^2 t} forward grows like eνk2te^{\nu k^2 t} backward, so rounding and truncation errors in high wavenumbers explode. The adjoint λ′=−Ju⊤λ\lambda' = -J_u^\top\lambda with a symmetric negative definite JuJ_u decays when run backward, so it is stable. Only the re-creation of uu fails.
    6. A model has n=107n = 10^7 state variables and 3 parameters, with a loss on the final state. Adjoint or forward sensitivities?
      AnswerForward sensitivities: 3 tangent solves run together with the forward solve and store no trajectory. The adjoint needs only one backward solve, but it must store or recompute NN states of size 10710^7. With 3 parameters, forward mode is simpler and needs less memory.
    Recall cardsAnswer in your head first, then reveal. Add the cards to your review deck to see them again just before you would forget.
    1. State the adjoint ODE and its condition for the loss L=ℓ(u(T))L = \ell(u(T)).

    2. Give dL/dpdL/dp and dL/du0dL/du_0 from the adjoint λ\lambda.

    3. Write the forward sensitivity equation for S=∂u/∂pS = \partial u/\partial p.

    4. Why is a residual network a discretized ODE?

    5. Which gradient does automatic differentiation through a solver compute?

    6. Why can the backsolve adjoint fail on a damped or stiff problem?

    7. How many checkpoints minimize memory for NN steps, and what does it cost?

    8. Cost of the gradient for np=106n_p = 10^6 parameters: central differences versus adjoint?

    9. Why can a one-dimensional neural ODE not learn the map u↦−uu \mapsto -u?

    Open the review →

    9. Interview angle #

    • "How does a neural ODE compute its gradients, and what can go wrong?" — The original paper uses the backsolve adjoint with O(1)O(1) memory. The gradient is approximate, and the backward re-creation of the state can be unstable. Backpropagation through the solver with checkpointing is usually preferred.
    • "Discretize-then-optimize or optimize-then-discretize?" — The first is the exact gradient of the computed loss; the second is an O(hq)O(h^q) approximation of the true gradient. They agree only in special cases.
    • "Backprop through a 1000-step simulation runs out of memory. What are your options?" — Checkpointing (N\sqrt N uniform or binomial), reversible solvers, forward sensitivities if there are few parameters, shorter rollouts, lower-precision stored states.
    • "Derive the adjoint ODE." — The five steps of Theorem 1: tangent equation, product rule, the JuJ_u terms cancel, integrate, read off the gradients.
    • "What is an adjoint model in data assimilation?" — The transpose of the tangent-linear model, run backward over the window. It gives the gradient of the 4D-Var cost with respect to the whole initial state for a small multiple of the cost of one forecast.

    10. Practice #

    The notebook for this lesson has five exercises, each with a check cell and a folded solution:

    1. Forward sensitivity for u′=−puu' = -pu: solve S′=−pS−uS' = -pS - u together with uu and compare with the exact derivative.
    2. The discrete adjoint of forward Euler by hand, checked against central finite differences.
    3. Lorenz-63 with RK4: the discrete adjoint through the RK4 stages, the continuous (backsolve) adjoint, and the convergence order of the continuous one.
    4. Uniform checkpointing: the same gradient with about 2N2\sqrt N stored states.
    5. A tiny neural ODE u′=θ1u+θ2u2u' = \theta_1u + \theta_2u^2, fitted to observations by gradient descent with the adjoint gradient.

    11. Watch and read #

    The forward and the adjoint sensitivity equations derived on the board, with their costs.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    A slow derivation of the adjoint ODE with the Lagrangian, as in the folded proof of Theorem 1.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    Neural ODEs as models: the ResNet connection and the uses in science.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play