3.4 Differentiating through solvers#
Parts 1 and 2 used hand-written explicit time steps, chosen small enough that the
integration scheme never asserted itself. Real models are not so polite: their physics
spans timescales from seconds to millennia, and their solvers — adaptive, implicit,
stiff-aware — are sophisticated numerical objects. Differentiating through a solver
raises questions the solver’s forward accuracy does not answer: the derivative of a
numerical solution is its own numerical problem, with its own stability theory. This
chapter demonstrates the two failure modes that matter in practice, and the library
(diffrax [Kidger, 2022]) that packages the solutions.
Learning goals
Recognize stiffness and why explicit integrators fail on it
Use
diffraxto solve and differentiate ODEs with explicit and implicit methodsDemonstrate that the continuous (‘backsolve’) adjoint is unstable for dissipative systems
Choose an adjoint method deliberately, with a working decision rule
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
jax.config.update("jax_enable_x64", True) # climate work needs double precision
# Running on Colab? First run: %pip install -q optax equinox diffrax
A stiff climate problem in two boxes#
The minimal model with a real timescale separation: a thin ocean surface layer (heat capacity of ~2 m of water, responding within weeks) exchanging heat with a deep reservoir (~500 m, responding over decades), under a constant 4 W m⁻² forcing — the classic two-box structure used to emulate the transient climate response:
C_SURF, C_DEEP = 8.2e6, 2.05e9 # J m^-2 K^-1 (~2 m and ~500 m of water)
B_FEEDBACK, KAPPA = 2.0, 1.0 # W m^-2 K^-1
FORCING = 4.0 # W m^-2
YEAR = 365.25 * 86400.0
def tendency(t, T, kappa):
Ts, Td = T
return jnp.array([(FORCING - B_FEEDBACK * Ts - kappa * (Ts - Td)) / C_SURF,
kappa * (Ts - Td) / C_DEEP])
tau_fast = C_SURF / (B_FEEDBACK + KAPPA) / 86400
print(f"surface timescale ~ {tau_fast:.0f} days; deep timescale ~ {C_DEEP/KAPPA/YEAR:.0f} years")
print(f"stiffness ratio ~ {C_DEEP / KAPPA / (C_SURF / (B_FEEDBACK + KAPPA)):.0f}")
surface timescale ~ 32 days; deep timescale ~ 65 years
stiffness ratio ~ 750
Stiffness is exactly this: the fastest process limits the time step, the slowest sets the simulation length, and their ratio (here ~750; in a real ESM, far larger) multiplies the cost. An explicit scheme must resolve the fast process even after it has equilibrated and become boring — exceed its stability limit and the solution does not degrade gracefully, it detonates:
def explicit_euler(kappa, dt_days, years=50):
dt_s = dt_days * 86400
def step(T, _):
return T + dt_s * tendency(0.0, T, kappa), None
T, _ = jax.lax.scan(step, jnp.zeros(2), None, length=int(years * YEAR / dt_s))
return T
for dt_days in [30, 100]:
T = explicit_euler(KAPPA, dt_days)
print(f"explicit Euler, dt = {dt_days:3d} days: T_surface = {float(T[0]):11.3e} K"
f"{' <- stable (limit ~63 days)' if abs(T[0]) < 100 else ' <- unstable'}")
explicit Euler, dt = 30 days: T_surface = 1.600e+00 K <- stable (limit ~63 days)
explicit Euler, dt = 100 days: T_surface = -1.208e+61 K <- unstable
Solvers as library calls: diffrax#
diffrax provides differentiable ODE/SDE solvers for JAX — adaptive step control,
explicit and implicit methods, and several adjoint strategies, all composable with
grad. The same 50-year problem, solved by an adaptive explicit method (Tsit5, a
Runge-Kutta workhorse) and an implicit one built for stiffness (Kvaerno5, which
solves a nonlinear system each step and is differentiated through that solve by the
implicit function theorem — Chapter 3.2’s trick, applied per step):
import diffrax
def solve(kappa, solver, adjoint=diffrax.RecursiveCheckpointAdjoint(),
rtol=1e-8, atol=1e-10):
sol = diffrax.diffeqsolve(
diffrax.ODETerm(tendency), solver, t0=0.0, t1=50 * YEAR, dt0=86400.0,
y0=jnp.zeros(2), args=kappa,
stepsize_controller=diffrax.PIDController(rtol=rtol, atol=atol),
adjoint=adjoint, max_steps=1_000_000)
return sol.ys[0], int(sol.stats["num_steps"])
for name, solver in [("Tsit5 (explicit)", diffrax.Tsit5()),
("Kvaerno5 (implicit)", diffrax.Kvaerno5())]:
(Ts, Td), n = solve(KAPPA, solver)
print(f"{name:20s}: T = ({float(Ts):.4f}, {float(Td):.4f}) K in {n:4d} steps")
Tsit5 (explicit) : T = (1.6001, 0.8014) K in 255 steps
Kvaerno5 (implicit) : T = (1.6001, 0.8014) K in 53 steps
Identical answers; the implicit method takes 5× fewer steps on this mildly stiff toy, and the advantage grows with the stiffness ratio. For the boundary-layer, microphysics, and chemistry timescales inside real ESMs, implicit and semi-implicit methods are not an optimization but the entry fee — which is why “differentiate through an implicit solve” (rather than around it) is core infrastructure for differentiable climate models.
Choosing the adjoint: a consequential checkbox#
Differentiating an ODE solution admits two philosophies. Discretize-then-
differentiate applies reverse-mode AD to the solver’s actual arithmetic — the exact
gradient of the computed solution, with the memory story of Chapter 3.2
(RecursiveCheckpointAdjoint, diffrax’s default, uses the optimal checkpointing
schedule). Differentiate-then-discretize (BacksolveAdjoint, the “neural ODE”
adjoint) instead derives a continuous adjoint ODE and integrates it backward in
time alongside a re-integration of the state, using only \(O(1)\) memory.
The backsolve looks like a free lunch, and for the dissipative systems of climate science it is a trap with a clean mathematical cause: a system that damps perturbations forward in time amplifies them backward. Re-integrating our stable two-box model in reverse is exponentially unstable, and the gradient does not degrade subtly:
def grad_of_deep_T(adjoint, rtol, atol):
def deep_T(kappa):
(Ts, Td), _ = solve(kappa, diffrax.Kvaerno5(), adjoint, rtol, atol)
return Td
return float(jax.grad(deep_T)(KAPPA))
g_ref = grad_of_deep_T(diffrax.RecursiveCheckpointAdjoint(), 1e-10, 1e-12)
print(f"reference dT_deep/dkappa = {g_ref:.6f} K per (W m^-2 K^-1)")
print()
print("tolerance checkpointed adjoint backsolve adjoint")
for rtol, atol in [(1e-8, 1e-10), (1e-4, 1e-6)]:
gc = grad_of_deep_T(diffrax.RecursiveCheckpointAdjoint(), rtol, atol)
gb = grad_of_deep_T(diffrax.BacksolveAdjoint(), rtol, atol)
print(f" {rtol:g} error {abs(gc - g_ref):10.2e} error {abs(gb - g_ref):10.2e}")
reference dT_deep/dkappa = 0.409117 K per (W m^-2 K^-1)
tolerance checkpointed adjoint backsolve adjoint
1e-08 error 6.91e-09 error 2.74e+232
0.0001 error 6.24e-07 error 3.62e+238
The checkpointed adjoint is accurate to solver tolerance; the backsolve gradient is wrong by hundreds of orders of magnitude — not noisy, destroyed, at any tolerance. The failure is silent: the forward solution from the same call is perfect. This checkbox has burned enough practitioners that it deserves stating as a rule: for dissipative (climate-like) dynamics, use the checkpointed/discrete adjoint; reserve the backsolve for the non-stiff, non-dissipative problems it was designed for.
One more cell connects this chapter to 3.1. On a chaotic system, both adjoints agree — and both are equally useless at long windows, because the pathology is the question, not the integrator:
K_L96 = 40
def l96(t, u, F):
return (jnp.roll(u, -1) - jnp.roll(u, 2)) * jnp.roll(u, 1) - u + F
u0 = 8.0 + 0.5 * jax.random.normal(jax.random.key(0), (K_L96,))
term = diffrax.ODETerm(l96)
ctrl = diffrax.PIDController(rtol=1e-8, atol=1e-8)
u_spun = diffrax.diffeqsolve(term, diffrax.Tsit5(), 0.0, 20.0, 0.01, u0, args=8.0,
stepsize_controller=ctrl, max_steps=100_000).ys[0]
def mean_state(F, adjoint, t_end):
sol = diffrax.diffeqsolve(term, diffrax.Tsit5(), 0.0, t_end, 0.01, u_spun, args=F,
stepsize_controller=ctrl, adjoint=adjoint, max_steps=100_000,
saveat=diffrax.SaveAt(ts=jnp.linspace(0.0, t_end, 200)))
return jnp.mean(sol.ys)
print("window checkpointed backsolve")
for t_end in [2.0, 5.0, 10.0]:
gc = float(jax.grad(mean_state)(8.0, diffrax.RecursiveCheckpointAdjoint(), t_end))
gb = float(jax.grad(mean_state)(8.0, diffrax.BacksolveAdjoint(), t_end))
print(f" {t_end:4.0f} {gc:+12.4f} {gb:+12.4f}")
window checkpointed backsolve
2 +0.0840 +0.0840
5 +3.0780 +3.0780
10 -15110.5301 -15110.5304
At window 10 the two methods agree to five digits — on a gradient four orders of magnitude too large to use. Integrator choices cannot repair an ill-posed question; they can only avoid adding numerical damage to a well-posed one.
Decision guide#
Situation |
Approach |
|---|---|
Non-stiff, short rollout |
hand-rolled step + |
Long rollout, memory-bound |
checkpointing ( |
Stiff dynamics |
implicit solver ( |
Output is an equilibrium |
implicit-function-theorem gradient at the fixed point (Chapter 3.2) |
Dissipative dynamics |
never |
Chaotic dynamics, long window |
not a solver problem — reformulate the question (Chapter 3.1) |
Exercises.
Sweep the backsolve gradient’s error as the integration length grows from 1 to 50 years. Estimate the instability’s e-folding time and compare it to \(\tau_{fast}\).
Solve the two-box model with
diffrax.ImplicitEulerat fixed 1-year steps. How accurate are the solution and its \(\kappa\)-gradient compared to Kvaerno5?Add a seasonal cycle to the forcing. Which solver settings change, and does the adjoint conclusion survive?