Part V: Discovery Through Simulation and Optimization
Chapter 42: Differentiable Programming for Discovery

42.3 Differentiable Physics and Molecular Dynamics

"I asked my simulation for the gradient and it replied with a 50,000-step chain rule. I asked the adjoint method and it replied with a single backward ODE. We got the same answer, but the adjoint still had memory left."

A Lagrange Multiplier With Boundary Conditions
The Big Picture

A pharmaceutical team runs a million-step molecular dynamics simulation of a drug binding to a protein, tweaks one force field parameter by 0.1%, and needs to know whether the predicted binding energy goes up or down; rerunning the entire simulation for every parameter is out of the question, yet the answer hides at the end of a 50,000-variable chain of coupled differential equations. If every operation in that sequence is differentiable, we can compute how the simulation's output (an observable like energy, diffusion coefficient, or radial distribution function (RDF)) changes with respect to its inputs (force field parameters, initial conditions, boundary conditions). This section derives the adjoint method for ODE-constrained optimization, shows how it avoids the memory explosion of naive backpropagation through time, and demonstrates differentiable molecular dynamics (MD) with JAX-MD and diffrax.

1. The ODE-Constrained Optimization Problem

Without a way to propagate sensitivity through a simulation, every parameter tweak demands a full rerun; for a million-step molecular dynamics trajectory, that means weeks of compute per candidate force field. The adjoint method collapses that cost to a single backward pass, making gradient-based calibration practical for problems that were previously intractable.

Many scientific discovery problems have the following structure. We have a dynamical system governed by an ordinary differential equation (ODE):

$$\frac{dz}{dt} = f(z(t), \theta), \quad z(0) = z_0$$

where \(z(t) \in \mathbb{R}^d\) is the state (particle positions and velocities, field values, concentrations), \(\theta \in \mathbb{R}^p\) are parameters we want to optimize (force field coefficients, reaction rates, material properties), and \(f\) is the dynamics function. We observe some quantity at the end of the simulation:

$$L(\theta) = \ell(z(T), \theta)$$

and we want \(\nabla_\theta L\): how does the loss change when we adjust the parameters? This is ODE-constrained optimization, and it is the mathematical backbone of differentiable physics.

Formal Definition and When to Use It

ODE-constrained optimization finds parameters \(\theta\) that minimize a loss \(L(\theta)\) when \(L\) depends on \(\theta\) only through a differential equation's solution. It matters because nearly every physical simulation, from climate models to drug binding trajectories, solves differential equations whose parameters we tune to match experimental data. The approach treats the ODE as a constraint and propagates sensitivity information through the dynamics to compute \(\nabla_\theta L\). Two directions are possible: forward (tangent method, cost \(O(p)\) per parameter) or backward (adjoint method, cost independent of parameter count). Use ODE-constrained optimization when the simulation is differentiable and the parameter count \(p\) is moderate to large; for non-differentiable or chaotic simulations, prefer gradient-free approaches such as Bayesian optimization or evolutionary strategies (covered in Chapter 45).

2. Naive Approach: Backpropagation Through Time

If we discretize the ODE with \(N\) time steps using a simple Euler integrator:

$$z_{n+1} = z_n + \Delta t \, f(z_n, \theta)$$

then the full computation is a chain of \(N\) differentiable operations. Standard reverse-mode automatic differentiation (AD) (backpropagation) stores all intermediate states \(z_0, z_1, \ldots, z_N\) and walks backward through the chain to compute the gradient. This produces exact gradients but requires \(O(N \cdot d)\) memory for the trajectory. For molecular dynamics with \(N = 10^5\) steps and \(d = 3 \times 10^4\) atoms, this is hundreds of gigabytes, far exceeding GPU memory.

Gradient checkpointing (discussed in Section 42.1) reduces the memory to \(O(\sqrt{N} \cdot d)\) at the cost of recomputation, but the adjoint method offers a more elegant solution that is particularly natural for ODEs.

3. The Continuous Adjoint Method

The adjoint method reformulates the gradient computation as a backward ODE that runs in constant memory, regardless of the number of time steps. The derivation proceeds in three steps. Figure 42.3 below illustrates the complete data flow from forward simulation through the backward adjoint pass.

Forward Pass (t = 0 to T) Initial State z(0) = z₀ Forward ODE dz/dt = f(z, θ) Final State z(T) Loss Function L = ℓ(z(T), θ) Backward Pass (t = T to 0) Adjoint Init a(T) = ∂ℓ/∂z Backward Adjoint ODE da/dt = −aᵀ ∂f/∂z Gradient Accumulation dL/dθ = ∫ aᵀ ∂f/∂θ dt Parameter Gradients ∇θ L (all parameters, one pass) Memory: O(d + p) vs. O(N·d) for naive backprop
Figure 42.3: Data flow of the continuous adjoint method. The forward pass (blue) integrates the ODE from \(t = 0\) to \(T\) to produce the final state. The backward pass (red) initializes the adjoint variable from the loss gradient and integrates a second ODE from \(T\) back to \(0\), accumulating parameter gradients along the way. The result (green) is the full gradient \(\nabla_\theta L\) in \(O(d + p)\) memory, compared to \(O(N \cdot d)\) for naive backpropagation.

Step 1: Define the adjoint variable. Let \(a(t) = \frac{\partial L}{\partial z(t)} \in \mathbb{R}^d\) be the sensitivity of the loss with respect to the state at time \(t\). At the final time, we know \(a(T) = \frac{\partial \ell}{\partial z(T)}\).

Step 2: Derive the adjoint ODE. By the chain rule, the adjoint evolves backward in time according to:

$$\frac{da}{dt} = -a(t)^T \frac{\partial f}{\partial z}(z(t), \theta)$$

This is the transpose of the forward dynamics' Jacobian, applied to the adjoint. In the language of Section 42.1, it is a continuous-time vector-Jacobian product (VJP), where the Jacobian \(\partial f / \partial z\) is the matrix of partial derivatives of the dynamics with respect to the state: the adjoint at each instant is the VJP of the adjoint with the dynamics Jacobian.

Step 3: Accumulate parameter gradients. The gradient with respect to \(\theta\) is obtained by integrating along the backward trajectory:

$$\frac{dL}{d\theta} = \int_T^0 a(t)^T \frac{\partial f}{\partial \theta}(z(t), \theta) \, dt$$

In practice, rather than solving three separate equations, we combine the state \(z\), the adjoint \(a\), and the running parameter gradient \(dL/d\theta\) into a single "augmented" system and integrate it backward in one pass:

$$\frac{d}{dt}\begin{pmatrix} z \\ a \\ \frac{dL}{d\theta} \end{pmatrix} = \begin{pmatrix} f(z, \theta) \\ -a^T \frac{\partial f}{\partial z} \\ -a^T \frac{\partial f}{\partial \theta} \end{pmatrix}$$

starting from \(t = T\) with initial conditions \((z(T), a(T), 0)\) and integrating to \(t = 0\). The state \(z(t)\) can be reconstructed by solving the forward ODE backwards (or by using checkpoints for numerical stability), so the method requires only \(O(d + p)\) memory: the current state, the current adjoint, and the accumulated parameter gradient, as shown in Figure 42.3. For a 30,000-atom system over 100,000 steps, naive backpropagation demands hundreds of gigabytes; the adjoint method typically needs on the order of hundreds of kilobytes. In short: the adjoint method turns "solve the whole simulation again for every parameter" into "solve one backward ODE once for all parameters." Figure 42.3.1 illustrates the continuous adjoint method for ODE-constrained optimization.

Continuous adjoint method for ODE-constrained optimization
Figure 42.3.1: The continuous adjoint method solves an augmented ODE backward from t=T to t=0, propagating the adjoint state a(t) alongside the reconstructed forward trajectory to accumulate parameter gradients in O(d+p) memory.

Mental Model

Adjoint method as launching a sensor boat upstream to find contamination source, carrying only a small notebook instead of storing every water sample

Think of the adjoint method like tracing a river upstream to find the source of contamination. A factory downstream (the loss function) detects a chemical at concentration \(C\). You could sample the water at every meter along the entire river, store every sample, and then walk backward through your samples to find where the contaminant entered (backpropagation through time). That works, but you need a warehouse of sample bottles. The adjoint method instead launches a single sensor boat at the factory that reads the current at each point and rides upstream, continuously updating its estimate of where the contamination originates. The boat carries only its current reading and its running tally of candidate sources (the adjoint state and the accumulated parameter gradient), never needing to store the state of the entire river. It arrives at the headwaters with the same answer, having carried only a small notebook instead of a warehouse.

Common Misconception

A frequent misconception is that the adjoint method produces approximate gradients because it avoids storing the full trajectory. In fact, the continuous adjoint computes the exact gradient of the continuous ODE; any discrepancy with backpropagation through the discretized solver arises from the difference between differentiating the continuous equations versus differentiating the discrete solver steps, not from any approximation in the adjoint formulation itself.

Key Insight: The Adjoint ODE is a Continuous VJP

The adjoint equation \(da/dt = -a^T (\partial f / \partial z)\) is the continuous-time analog of the reverse-mode AD identity \(\bar{x} = J^T \bar{y}\) from Section 42.1. Each infinitesimal time step has a Jacobian \(I + \Delta t \, \partial f / \partial z\); the adjoint equation propagates the output sensitivity backward through this continuous chain of infinitesimal Jacobians. This is why Chen et al. (2018) called their Neural ODE paper's gradient computation "the adjoint sensitivity method": it is reverse-mode AD lifted from discrete computational graphs to continuous dynamical systems.

4. Implementing the Adjoint Method with diffrax

The diffrax library (by Patrick Kidger) provides differentiable ODE and stochastic differential equation (SDE) solvers in JAX. It supports multiple adjoint methods out of the box: direct backpropagation through the solver steps, the continuous adjoint, and recursive checkpointing. Here we solve a simple damped oscillator and compute gradients of a trajectory observable with respect to the damping coefficient.

import jax
import jax.numpy as jnp
import diffrax

def damped_oscillator(t, state, args):
    """Dynamics of a damped harmonic oscillator.

    Parameters
    ----------
    t : float
        Current time (unused for autonomous system).
    state : jnp.ndarray, shape (2,)
        [position, velocity].
    args : dict
        {'k': spring constant, 'gamma': damping coefficient}

    Returns
    -------
    jnp.ndarray, shape (2,)
        [velocity, acceleration].
    """
    x, v = state
    k, gamma = args['k'], args['gamma']
    return jnp.array([v, -k * x - gamma * v])

def simulate_and_observe(params):
    """Simulate a damped oscillator and return the final displacement.

    Parameters
    ----------
    params : dict
        {'k': float, 'gamma': float}

    Returns
    -------
    float
        Squared final displacement (the observable we optimize).
    """
    state0 = jnp.array([1.0, 0.0])  # start displaced, at rest

    solver = diffrax.Tsit5()         # Tsitouras 5th-order method
    term = diffrax.ODETerm(damped_oscillator)
    saveat = diffrax.SaveAt(t1=True)

    # Use RecursiveCheckpointAdjoint for memory-efficient gradients
    adjoint = diffrax.RecursiveCheckpointAdjoint()

    sol = diffrax.diffeqsolve(
        term, solver,
        t0=0.0, t1=10.0, dt0=0.01,
        y0=state0, args=params,
        saveat=saveat,
        adjoint=adjoint,
    )
    final_state = sol.ys[-1]
    return final_state[0] ** 2  # squared final displacement

# Gradient of the observable w.r.t. damping and spring constant
grad_fn = jax.jit(jax.grad(simulate_and_observe))

params = {'k': 1.0, 'gamma': 0.5}
grads = grad_fn(params)
print(f"d(x_final^2)/dk:     {grads['k']:.8f}")
print(f"d(x_final^2)/dgamma: {grads['gamma']:.8f}")

# Verify with finite differences
import copy
h = 1e-5
for name in ['k', 'gamma']:
    p_plus = copy.copy(params)
    p_plus[name] = params[name] + h
    p_minus = copy.copy(params)
    p_minus[name] = params[name] - h
    fd = (simulate_and_observe(p_plus) - simulate_and_observe(p_minus)) / (2 * h)
    print(f"d(x_final^2)/d{name} [finite diff]: {fd:.8f}")
Listing 42.15: Differentiable ODE solving with diffrax, computing gradients of a damped oscillator's final displacement with respect to spring and damping constants. The RecursiveCheckpointAdjoint computes gradients through the ODE solve with bounded memory, matching finite-difference results to high precision.

The RecursiveCheckpointAdjoint combines the adjoint method with optimal checkpointing: it stores \(O(\log N)\) intermediate states and recomputes the rest, giving both memory efficiency and numerical stability. For long simulations (\(N > 10^4\) steps), this is dramatically cheaper than storing the full trajectory.

5. Differentiable Molecular Dynamics with JAX-MD

The adjoint method and diffrax handle generic ODE plumbing, but a real physical system adds thousands of interacting particles, neighbor lists, and periodic boundary conditions.

JAX-MD (Schoenholz and Cubuk, 2020) is a molecular dynamics library built entirely in JAX. Molecular dynamics is a particularly natural fit for differentiable programming because its core operations (computing pairwise distances, evaluating smooth potential energy functions, deriving forces as gradients of energy) are already differentiable by construction; the simulation is a long chain of these smooth operations, so AD can propagate sensitivity through the entire trajectory without special handling. Because every operation (neighbor list construction, force computation, integration) is a JAX function, the entire MD simulation is automatically differentiable. This means you can compute gradients of any trajectory-averaged observable (energy, pressure, RDF) with respect to force field parameters.

import jax
import jax.numpy as jnp
from jax_md import space, energy, simulate, quantity, partition

# Set up a periodic simulation box
box_size = 10.0
displacement_fn, shift_fn = space.periodic(box_size)

# Define a Lennard-Jones potential with learnable parameters
def lj_energy_fn(dr, epsilon=1.0, sigma=1.0):
    """Lennard-Jones pair energy as a function of distance.

    Parameters
    ----------
    dr : float
        Pairwise distance.
    epsilon : float
        Well depth parameter.
    sigma : float
        Length scale parameter.

    Returns
    -------
    float
        Pair interaction energy.
    """
    idr6 = (sigma / dr) ** 6
    return 4.0 * epsilon * (idr6 ** 2 - idr6)

# Build the neighbor list for efficient pair enumeration
key = jax.random.key(0)
N = 64
positions = jax.random.uniform(key, (N, 3)) * box_size

# JAX-MD energy function from pair potential
energy_fn = energy.pair(
    lj_energy_fn,
    displacement_or_metric=displacement_fn,
    sigma=1.0,
    epsilon=1.0,
)

# Neighbor list for O(N) force computation
neighbor_fn = partition.neighbor_list(
    displacement_fn,
    box=box_size,
    r_cutoff=2.5,
    capacity_multiplier=1.5,
)
neighbors = neighbor_fn.allocate(positions)

# Compute energy and forces
E = energy_fn(positions, neighbor=neighbors)
force_fn = quantity.force(energy_fn)
forces = force_fn(positions, neighbor=neighbors)

print(f"Total energy:  {E:.4f}")
print(f"Forces shape:  {forces.shape}")
print(f"Max force mag: {jnp.max(jnp.linalg.norm(forces, axis=-1)):.4f}")
Listing 42.16: Lennard-Jones energy and force computation in JAX-MD with neighbor lists. The energy function, neighbor list, and force computation are all JAX functions, making the entire pipeline differentiable.
Practical Example: Learning a Force Field from Ab Initio Data

A common workflow in computational chemistry is fitting a classical force field to density functional theory (DFT) or CCSD(T) reference data, where CCSD(T) is coupled cluster theory with single, double, and perturbative triple excitations, a high-accuracy quantum chemistry method. Traditionally, this involves running many simulations with trial parameters, computing observables, comparing to reference, and manually adjusting. With differentiable MD, the process becomes a gradient descent loop: (1) run a short MD trajectory with current parameters, (2) compute the loss between predicted and reference forces/energies, (3) backpropagate through the trajectory to get parameter gradients, (4) update parameters with an optimizer. Projects like TorchMD-Net and SchNet use this approach with neural network potentials, in many benchmarks approaching quantum-mechanical accuracy while running at classical MD speed. The recipe in Section 42.4 implements this workflow for a simple Lennard-Jones system.

6. Running a Differentiable MD Simulation

JAX-MD provides integrators (Velocity Verlet, a symplectic integrator that updates positions and velocities in a leapfrog pattern to conserve energy over long trajectories, Nose-Hoover, a deterministic thermostat that maintains constant temperature by coupling the system to an auxiliary dynamic variable, and Langevin) that are themselves JAX functions. We can run a short constant particle number, volume, and temperature (NVT) trajectory, compute an observable, and differentiate through the entire simulation.

import jax
import jax.numpy as jnp
from jax_md import simulate, space, energy, partition

def run_nvt_simulation(params, positions, key, n_steps=500, dt=0.001):
    """Run a short NVT (constant number, volume, temperature) simulation and return the mean kinetic energy.

    Parameters
    ----------
    params : dict
        {'epsilon': float, 'sigma': float}
    positions : jnp.ndarray, shape (N, 3)
        Initial particle positions.
    key : jax.random.PRNGKey
        Random key for thermostat noise.
    n_steps : int
        Number of simulation steps.
    dt : float
        Integration time step.

    Returns
    -------
    float
        Time-averaged kinetic energy per particle.
    """
    box_size = 10.0
    displacement_fn, shift_fn = space.periodic(box_size)

    # Build energy function with current parameters
    energy_fn = energy.pair(
        lambda dr, **kwargs: 4.0 * params['epsilon'] * (
            (params['sigma'] / dr) ** 12 - (params['sigma'] / dr) ** 6
        ),
        displacement_or_metric=displacement_fn,
    )

    neighbor_fn = partition.neighbor_list(
        displacement_fn, box=box_size, r_cutoff=2.5 * params['sigma'],
    )
    neighbors = neighbor_fn.allocate(positions)

    # NVT (Nose-Hoover) integrator at target temperature
    kT = 1.0
    init_fn, step_fn = simulate.nvt_nose_hoover(energy_fn, shift_fn, dt, kT)
    state = init_fn(key, positions, neighbor=neighbors)

    # Use lax.scan for a differentiable loop
    def scan_step(carry, _):
        state, nbrs = carry
        state = step_fn(state, neighbor=nbrs)
        ke = quantity.kinetic_energy(state.velocity)
        return (state, nbrs), ke

    (final_state, _), ke_trajectory = jax.lax.scan(
        scan_step, (state, neighbors), None, length=n_steps
    )

    N = positions.shape[0]
    return jnp.mean(ke_trajectory) / N

# Differentiate the mean kinetic energy w.r.t. force field parameters
grad_ke = jax.grad(run_nvt_simulation)

params = {'epsilon': 1.0, 'sigma': 1.0}
key = jax.random.key(42)
positions = jax.random.uniform(jax.random.key(0), (32, 3)) * 10.0

# This backpropagates through 500 MD steps!
grads = grad_ke(params, positions, key)
print(f"d(KE)/d(epsilon): {grads['epsilon']:.6f}")
print(f"d(KE)/d(sigma):   {grads['sigma']:.6f}")
Listing 42.17: Backpropagating through a 500-step Nose-Hoover NVT simulation to compute kinetic energy gradients. jax.lax.scan provides a differentiable loop; jax.grad propagates sensitivity through every time step to yield force field parameter gradients.

7. Memory Management: Checkpointing Long Trajectories

The jax.lax.scan in Listing 42.17 stores all intermediate states for backpropagation. For long trajectories, wrap the scan body with jax.checkpoint to trade recomputation for memory:

Real-World Application: Drug Binding Free Energy Prediction
Real-World Application: Drug Binding Free Energy Prediction
import jax
import functools

def scan_with_checkpointing(f, init, xs, length, checkpoint_every=50):
    """A scan that checkpoints every K steps for memory efficiency.

    Parameters
    ----------
    f : callable
        Step function (carry, x) -> (carry, y).
    init : pytree
        Initial carry value.
    xs : pytree or None
        Inputs scanned over (None if no per-step input).
    length : int
        Number of scan steps.
    checkpoint_every : int
        Checkpoint interval.

    Returns
    -------
    tuple
        (final_carry, stacked_outputs)
    """
    @jax.checkpoint
    def checkpointed_segment(carry, segment_xs):
        return jax.lax.scan(f, carry, segment_xs)

    n_segments = length // checkpoint_every
    remainder = length % checkpoint_every

    if xs is None:
        segments = None
        segment_length = checkpoint_every
    else:
        # Reshape inputs into segments
        segments = jax.tree.map(
            lambda x: x[:n_segments * checkpoint_every].reshape(
                n_segments, checkpoint_every, *x.shape[1:]
            ), xs
        )
        segment_length = checkpoint_every

    # Process segments with checkpointing
    def outer_step(carry, segment_x):
        return checkpointed_segment(carry, segment_x)

    if segments is not None:
        carry, outputs = jax.lax.scan(outer_step, init, segments)
        # Flatten the segmented outputs
        outputs = jax.tree.map(
            lambda x: x.reshape(-1, *x.shape[2:]), outputs
        )
    else:
        # No per-step inputs: use None placeholders
        carry = init
        all_outputs = []
        for _ in range(n_segments):
            carry, seg_out = checkpointed_segment(
                carry, jnp.zeros(checkpoint_every)  # dummy
            )
            all_outputs.append(seg_out)

    return carry, outputs

# Usage: only O(length / checkpoint_every) states stored
# instead of O(length)
Listing 42.18: Segmented checkpointing wrapper for jax.lax.scan that divides a long trajectory into segments. By wrapping inner scan segments with jax.checkpoint, memory usage drops from \(O(N)\) to \(O(N / K)\) where \(K\) is the checkpoint interval.

8. Beyond ODEs: Differentiable Discrete Simulations

The checkpointing strategies above assume a simulation built from smooth, continuous update steps, but many scientific models rely on discrete events, stochastic sampling, or lattice updates that have no well-defined Jacobian at every point.

Not all physics simulations are continuous ODEs. Discrete event simulations, Monte Carlo samplers, and lattice models require different differentiation strategies. Two approaches extend differentiable programming to these settings:

Checkpoint

So far in this subsection: straight-through estimators replace non-differentiable operations with smooth surrogates during the backward pass, the Gumbel-Softmax trick provides a continuous relaxation for categorical sampling, and score function (REINFORCE) estimators bypass differentiation through discrete sampling entirely by using the log-probability gradient identity.

For molecular simulations specifically, the reparameterization trick, a technique that separates a random variable into a deterministic function of the parameters and an independent noise source so that gradients can flow through the deterministic part, often applies naturally. Langevin dynamics samples positions according to \(x_{t+1} = x_t + \Delta t \, F(x_t)/\gamma + \sqrt{2\Delta t / \gamma} \, \xi_t\) where \(\xi_t \sim \mathcal{N}(0, I)\). Because the noise \(\xi_t\) is independent of the parameters, we can differentiate through the dynamics while treating \(\xi_t\) as fixed. This is exactly what JAX-MD's Langevin integrator does.

Key Insight: Differentiable Simulation Closes the Loop

Traditional scientific computing treats simulation as a black box: parameters go in, observables come out, and optimization happens outside the simulation loop (grid search, evolutionary algorithms, Bayesian optimization). Differentiable simulation opens the box: gradients flow from observables back through every simulation step to parameters. This closes the optimization loop, enabling gradient-based calibration that converges in tens of iterations rather than thousands of black-box evaluations. The Chapter 45 discussion of optimization strategies compares gradient-based and gradient-free approaches quantitatively; differentiable programming is what makes the gradient-based option available for simulation-driven discovery.

9. TorchMD-Net: Neural Network Potentials in PyTorch

The PyTorch ecosystem supports differentiable MD through TorchMD-Net, which implements equivariant neural network potentials, neural networks whose predictions transform correctly under rotations and translations of the input coordinates, preserving physical symmetries, (SchNet, PaiNN, TensorNet) that predict energies and forces from atomic positions and species (as of 2024, TorchMD-Net has expanded to include ET (Equivariant Transformer) and integrates with MACE and other higher-order equivariant models). Where JAX-MD emphasizes composable pair potentials, TorchMD-Net uses message-passing neural networks on atomic graphs.

# TorchMD-Net: neural network potential in PyTorch
# (Shown for comparison; the chapter recipe uses JAX)
import torch
from torchmdnet.models.model import load_model

# Load a pretrained equivariant potential
model = load_model("torchmdnet_ani2x.pt")

# Predict energy and forces
positions = torch.randn(10, 3, requires_grad=True)  # 10 atoms
species = torch.tensor([6, 6, 6, 1, 1, 1, 1, 8, 7, 1])  # C, H, O, N

energy, forces = model(species, positions)
print(f"Energy: {energy.item():.4f} eV")
print(f"Forces shape: {forces.shape}")

# Gradient of energy w.r.t. model parameters for fine-tuning
loss = (energy - target_energy) ** 2
loss.backward()  # standard PyTorch backprop
# model.parameters() now have .grad attributes
Listing 42.19: Loading a pretrained TorchMD-Net equivariant potential and computing energy, forces, and parameter gradients via PyTorch autograd. The same differentiable programming principles apply: loss.backward() propagates gradients through the neural potential for fine-tuning.
Library Shortcut: diffrax Solves the ODE Plumbing

Implementing the adjoint method from scratch (Listing 42.15's internals) requires roughly 200 lines of careful numerical code: the augmented ODE system, the backward integration, interpolation of the forward trajectory, and error control. The diffrax library handles all of this in a single diffeqsolve call with the adjoint parameter. It supports five adjoint strategies: DirectAdjoint (backprop through solver steps), RecursiveCheckpointAdjoint (optimal checkpointing), BacksolveAdjoint (continuous adjoint with backward ODE), ImplicitAdjoint (for implicit solvers), and NoAdjoint (forward-only, no gradients). Choosing the right adjoint is a one-line change; the solver, step-size controller, and error tolerances remain the same.

10. When Differentiable Physics Breaks Down

Differentiable simulation is powerful but not universal. Several scenarios challenge or defeat gradient-based optimization through physics:

For discovery applications, these limitations motivate hybrid approaches: use differentiable simulation for short-horizon optimization (force field fitting, initial condition search) and gradient-free methods (Chapter 45) for problems where gradients are unreliable.

Research Frontier

MACE (Multi-Atomic Cluster Expansion), introduced by Batatia et al. (2022, NeurIPS), pushes differentiable molecular dynamics beyond pair and three-body potentials by learning higher-order equivariant message-passing representations that capture many-body interactions up to arbitrary correlation order. MACE-MP-0, a foundation model trained on the Materials Project database, achieves near-DFT accuracy across the periodic table while remaining fast enough for nanosecond-scale MD simulations (as of 2024, successor models such as MACE-MP-0b and MACE-OFF for organic molecules have further improved accuracy and coverage, and the MACE architecture has become a leading baseline in the Matbench Discovery leaderboard). The architecture is fully differentiable: gradients flow from trajectory-level observables through the MACE potential back to its learnable parameters, enabling fine-tuning on domain-specific data with only tens of reference structures. This represents a shift from hand-designed functional forms (Lennard-Jones, embedded atom method (EAM)) toward learned, many-body potentials that are both differentiable and transferable across chemical compositions.

Try It: Gradient-Based Spring Constant Fitting with diffrax

Build a mini parameter-fitting pipeline that recovers a spring constant from observed oscillator data, using only diffrax, JAX, and optax (a standard JAX optimizer library).

  1. Generate synthetic data. Using the damped_oscillator function from Listing 42.15, simulate a trajectory with known parameters (\(k = 2.0\), \(\gamma = 0.3\)) for \(t \in [0, 10]\). Save the position at 20 evenly spaced time points as your "experimental" reference data.
  2. Define the loss function. Write a function loss_fn(params) that solves the ODE with diffrax.diffeqsolve using candidate parameters, extracts positions at the same 20 time points via SaveAt(ts=...), and returns the mean squared error (MSE) against the reference data.
  3. Set up gradient descent. Initialize with a wrong guess (\(k = 5.0\), \(\gamma = 1.0\)). Use optax.adam(learning_rate=0.05) for the optimizer. In a loop of 200 iterations, call jax.value_and_grad(loss_fn), apply the update, and print the loss every 20 steps.
  4. Verify convergence. After training, compare the recovered \(k\) and \(\gamma\) to the true values. The recovered values should match to within 1% after roughly 100 iterations.
  5. Visualize. Plot the reference trajectory, the initial-guess trajectory, and the final fitted trajectory on one figure using matplotlib to confirm the fit visually.

Exercise 42.3.1

Consider a damped harmonic oscillator \(\ddot{x} = -kx - \gamma \dot{x}\) with \(k = 1.0\) and \(\gamma = 0.5\), integrated from \(x(0) = 1, \dot{x}(0) = 0\) to \(t = 10\) using Euler steps with \(\Delta t = 0.01\). The loss is \(L = x(T)^2\). If you store the full trajectory for backpropagation, how many state vectors must you retain? If you instead use the continuous adjoint method, what is the memory cost in terms of the state dimension \(d\) and the parameter count \(p\)? Finally, if you apply segmented checkpointing with a checkpoint interval of \(K = 50\), what is the memory cost?

Hint

The Euler integration runs for \(N = (10 - 0) / 0.01 = 1000\) steps, and each state vector has \(d = 2\) components (position and velocity). For naive backpropagation you store all \(N\) states. The continuous adjoint needs only the current state, the adjoint variable, and the accumulated parameter gradient: \(O(d + p)\). With segmented checkpointing at interval \(K\), you store \(O(N / K)\) checkpoint states plus the \(O(K)\) states within the currently recomputed segment.

Step-Through: Adjoint Backward Pass for a 3-Step Euler ODE

Trace the adjoint method on a tiny example. Consider \(dz/dt = -\theta z\) with \(z_0 = 2.0\), \(\theta = 0.5\), three Euler steps with \(\Delta t = 1.0\), and loss \(L = z_3^2\).

Forward pass:
\(z_0 = 2.0\)
\(z_1 = z_0 + 1.0 \cdot (-0.5 \cdot 2.0) = 2.0 - 1.0 = 1.0\)
\(z_2 = 1.0 + 1.0 \cdot (-0.5 \cdot 1.0) = 1.0 - 0.5 = 0.5\)
\(z_3 = 0.5 + 1.0 \cdot (-0.5 \cdot 0.5) = 0.5 - 0.25 = 0.25\)
\(L = 0.25^2 = 0.0625\)

Backward pass (adjoint):
\(a_3 = dL/dz_3 = 2 \cdot 0.25 = 0.5\)
At step 3: \(\partial f/\partial z = -\theta = -0.5\), so \(a_2 = a_3 \cdot (1 + \Delta t \cdot (-0.5)) = 0.5 \cdot 0.5 = 0.25\)
At step 2: \(a_1 = a_2 \cdot 0.5 = 0.125\)
At step 1: \(a_0 = a_1 \cdot 0.5 = 0.0625\)

Parameter gradient accumulation:
\(\partial f/\partial \theta = -z_n\) at each step, so:
\(dL/d\theta = a_3 \cdot \Delta t \cdot (-z_2) + a_2 \cdot \Delta t \cdot (-z_1) + a_1 \cdot \Delta t \cdot (-z_0)\)
\(= 0.5 \cdot (-0.5) + 0.25 \cdot (-1.0) + 0.125 \cdot (-2.0) = -0.25 - 0.25 - 0.25 = -0.75\)
Verification by finite differences: with \(\theta = 0.5 \pm 0.001\), you can confirm \(dL/d\theta \approx -0.75\).

Real-World Application: Drug Binding Free Energy Prediction

The Open Force Field Initiative uses differentiable molecular dynamics to optimize the SMIRNOFF force field parameters against quantum mechanical and experimental thermodynamic data. By backpropagating through alchemical free energy perturbation simulations, a technique that gradually transforms one molecule into another in silico to compute the free energy difference between them, the pipeline computes gradients of hydration free energy predictions with respect to hundreds of Lennard-Jones and torsion parameters simultaneously, reportedly converging in days rather than the months that traditional manual tuning often requires. Pharmaceutical companies use these optimized force fields to rank drug candidate binding affinities before synthesis, reducing the number of compounds that must be made and tested in the lab.

The 200-Year-Old Trick Behind Neural ODEs

The adjoint method that powers modern differentiable physics was not invented by machine learning researchers. Lagrange introduced the core idea of adjoint variables in the 1760s for celestial mechanics, and Pontryagin formalized the "maximum principle" for optimal control in 1956. When Chen et al. (2018) published the Neural ODE paper, they acknowledged that their gradient computation was simply the classical adjoint sensitivity method from optimal control theory, repackaged for neural networks. The paper's main contribution was not the math (which had been in control theory textbooks for decades) but the realization that treating a neural network as a continuous dynamical system lets you borrow 200 years of ODE theory for free. The JAX and PyTorch implementations in this section trace their intellectual lineage through control theory, celestial mechanics, and the calculus of variations, all the way back to Euler and Lagrange.

Lab: Recovering Lennard-Jones Parameters from a Radial Distribution Function

Goal: Use differentiable molecular dynamics to fit Lennard-Jones parameters (\(\epsilon\), \(\sigma\)) so that a simulated RDF matches a target RDF generated with known parameters.
Tools needed: JAX, JAX-MD, optax, matplotlib (all pip-installable).
Setup (5 min): Generate a reference RDF by running a 2000-step NVT simulation of 64 particles with \(\epsilon = 1.5\), \(\sigma = 1.2\) using JAX-MD's Nose-Hoover integrator. Compute the RDF from the final 1000 frames using quantity.pair_correlation.
Experiment (15 min): Initialize with wrong parameters (\(\epsilon = 1.0\), \(\sigma = 1.0\)). Define a loss as the MSE between the candidate RDF and the reference RDF. Use jax.grad to differentiate the loss through the simulation and update parameters with optax.adam(0.01) for 100 iterations.
What to vary: Try different initial guesses (closer vs. farther from the true values), different trajectory lengths (200 vs. 2000 steps), and different learning rates (0.001, 0.01, 0.1).
What to observe: Track the loss curve, the parameter trajectories (\(\epsilon(t)\), \(\sigma(t)\)), and overlay the fitted RDF against the reference at iterations 0, 25, 50, and 100. Note how shorter trajectories yield noisier gradients and slower convergence.

What's Next

Section 42.4: Building a Differentiable Force Field puts everything from this chapter into practice. We calibrate a Lennard-Jones force field by backpropagating through a molecular dynamics trajectory to match a target radial distribution function. The recipe combines reverse-mode AD, vmap, jit, and the checkpointed simulation loop from this section into a complete, runnable parameter optimization pipeline.

Bibliography

Core References

Chen, R. T. Q., Rubanova, Y., Bettencourt, J., & Duvenaud, D. (2018). Neural ordinary differential equations. NeurIPS 2018.

Applied the classical continuous adjoint sensitivity method to neural ODEs, enabling constant-memory gradient computation through ODE solvers.

Schoenholz, S. S. & Cubuk, E. D. (2020). JAX, M.D.: A framework for differentiable physics. NeurIPS 2020.

The JAX-MD library paper, demonstrating end-to-end differentiable molecular dynamics for learning interatomic potentials.

Kidger, P. (2022). On neural differential equations. PhD thesis, University of Oxford.

Comprehensive treatment of neural ODEs and SDEs, including the diffrax library and its adjoint method implementations.

Doerr, S., et al. (2021). TorchMD: A deep learning framework for molecular simulations. JCTC, 17(3), 2355–2363.

The TorchMD framework for differentiable molecular dynamics in PyTorch, enabling end-to-end learning of neural network potentials.

Griewank, A. & Walther, A. (2008). Evaluating Derivatives, 2nd ed. SIAM.

Covers checkpointing theory (the revolve algorithm) essential for memory-efficient differentiation through long simulations.