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

42.4 Building a Differentiable Force Field

"My force field had two parameters. My loss function had one million atoms. Reverse-mode AD did not even blink."

An Epsilon That Found Its Sigma
The Big Picture

What if you could taste a cake and, in one mental sweep, trace the too-sweet flavor backward through every mixing step to the exact moment you added too much sugar? That is precisely what this recipe does with a molecular simulation: we calibrate a Lennard-Jones force field by running molecular dynamics in JAX, computing the radial distribution function (RDF) from the trajectory, comparing it to a target RDF, and backpropagating the loss through the entire simulation to update force field parameters. The recipe demonstrates the full differentiable programming stack: reverse-mode automatic differentiation (AD) for gradients, vmap (vectorized map, which lifts a single-pair function to operate on all pairs simultaneously) for batching pair computations, jit (just-in-time compilation) for graphics processing unit (GPU) compilation, jax.lax.scan (a functional loop primitive that unrolls sequential steps while remaining differentiable) for differentiable time-stepping, and Optax (a JAX-based gradient-processing and optimization library from DeepMind) for optimization. By the end, you will have a complete, runnable pipeline for gradient-based force field calibration.

1. Problem Setup: Force Field Calibration

When a pharmaceutical company simulates a drug candidate docking into a protein pocket, every predicted binding energy rests on the accuracy of the underlying force field parameters. Shift those parameters by even a few percent and the simulation promotes the wrong molecule into clinical trials, wasting years and billions of dollars.

Force field calibration is one of the oldest problems in computational chemistry. A force field defines how atoms interact: the Lennard-Jones potential \(V(r) = 4\epsilon[(\sigma/r)^{12} - (\sigma/r)^6]\) has two parameters per atom type pair: \(\epsilon\) (well depth, controlling interaction strength) and \(\sigma\) (length scale, controlling the effective atom size). The goal is to find \(\epsilon\) and \(\sigma\) values that reproduce experimentally observed structural properties.

Force field calibration tunes the numerical constants in an interatomic potential so that simulations reproduce measurable physical properties (density, structure, diffusion) of a real material. It matters because even a physically motivated functional form like Lennard-Jones produces wildly wrong behavior if its parameters are off by 10%. That gap makes calibration the bottleneck between writing down a potential and trusting its predictions. The mechanism is an inverse problem: run a simulation forward with candidate parameters, compare the output to experimental observables, and adjust the parameters to shrink the mismatch. Use gradient-based calibration (as in this recipe) when the potential is differentiable and you have more than two or three parameters; for very small parameter spaces or non-differentiable potentials, grid search or Bayesian optimization may suffice.

The Observable: Radial Distribution Functions

The radial distribution function (RDF), \(g(r)\), measures the probability of finding a particle at distance \(r\) from another particle, normalized by the ideal gas density. Peaks in \(g(r)\) correspond to coordination shells. The RDF is one of the most commonly used structural observables because it can be measured experimentally (via X-ray or neutron scattering) and computed from simulation trajectories.

Our objective: given a target RDF \(g^*(r)\) (representing experimental data or a high-fidelity reference simulation), find \(\epsilon\) and \(\sigma\) such that the RDF from a simulation with those parameters matches \(g^*(r)\).

$$\mathcal{L}(\epsilon, \sigma) = \int_0^{r_\text{max}} \left[ g(r; \epsilon, \sigma) - g^*(r) \right]^2 dr$$

2. Generating the Target RDF

In a real application, the target RDF would come from experiment or a quantum mechanical simulation. For this recipe, we generate a synthetic target by running a simulation with known "true" parameters and computing the RDF. This lets us verify that optimization recovers the correct parameters. In short: if you can simulate it forward, you can differentiate it backward, turning any computable observable into a loss function for parameter learning.

import jax
import jax.numpy as jnp
from functools import partial

# --- Simulation infrastructure ---

def lj_energy_pair(r, epsilon, sigma):
    """Lennard-Jones pair energy with a smooth cutoff.

    Parameters
    ----------
    r : float
        Interatomic distance.
    epsilon : float
        Well depth.
    sigma : float
        Length scale.

    Returns
    -------
    float
        Pair energy, smoothly truncated at r_cut = 2.5*sigma.
    """
    r_cut = 2.5 * sigma
    s6 = (sigma / r) ** 6
    e_full = 4.0 * epsilon * (s6 ** 2 - s6)
    # Shift to zero at cutoff
    s6_cut = (sigma / r_cut) ** 6
    e_cut = 4.0 * epsilon * (s6_cut ** 2 - s6_cut)
    return jnp.where(r < r_cut, e_full - e_cut, 0.0)


def compute_total_energy(positions, box_size, epsilon, sigma):
    """Total LJ energy for N particles in a periodic box.

    Parameters
    ----------
    positions : jnp.ndarray, shape (N, 3)
        Particle positions.
    box_size : float
        Side length of the cubic periodic box.
    epsilon : float
        LJ well depth.
    sigma : float
        LJ length scale.

    Returns
    -------
    float
        Total potential energy.
    """
    N = positions.shape[0]

    def pair_energy(i, j):
        dr = positions[i] - positions[j]
        # Minimum image convention for periodic boundaries
        dr = dr - box_size * jnp.round(dr / box_size)
        r = jnp.sqrt(jnp.sum(dr ** 2) + 1e-12)
        return lj_energy_pair(r, epsilon, sigma)

    # Build all unique pairs
    idx_i, idx_j = jnp.triu_indices(N, k=1)
    pair_e = jax.vmap(pair_energy)(idx_i, idx_j)
    return jnp.sum(pair_e)


def compute_forces(positions, box_size, epsilon, sigma):
    """Compute forces via the negative gradient of total energy.

    Parameters
    ----------
    positions : jnp.ndarray, shape (N, 3)
        Particle positions.
    box_size : float
        Side length of cubic periodic box.
    epsilon : float
        LJ well depth.
    sigma : float
        LJ length scale.

    Returns
    -------
    jnp.ndarray, shape (N, 3)
        Force on each particle.
    """
    grad_fn = jax.grad(compute_total_energy, argnums=0)
    return -grad_fn(positions, box_size, epsilon, sigma)
Listing 42.20: Core simulation functions: LJ pair energy with smooth cutoff, total energy via vmap over pairs, and forces via jax.grad of the energy. The force computation is automatic: no hand-derived force expressions needed.
Key Insight: Forces Are Free

In classical molecular dynamics (MD), forces are derived by hand-differentiating the potential energy expression. For complex potentials (many-body terms, angular contributions, torsions), this is tedious and error-prone. With AD, forces come directly from jax.grad(energy, argnums=positions). The gradient is exact to machine precision, handles arbitrarily complex energy functions, and costs roughly 2x the energy evaluation. When we later differentiate the loss with respect to parameters, we are computing gradients of gradients: the parameter sensitivity of the forces. This second-order differentiation is equally automatic.

3. The Velocity Verlet Integrator

With forces computed automatically from the energy function, the next question is how to march those forces forward in time to produce a trajectory.

We use the Velocity Verlet algorithm, the standard integrator for molecular dynamics. It is symplectic (preserving the phase-space volume, a property that prevents energy drift over long simulations), time-reversible, and second-order accurate:

$$v(t + \tfrac{1}{2}\Delta t) = v(t) + \tfrac{1}{2}\Delta t \, a(t)$$ $$x(t + \Delta t) = x(t) + \Delta t \, v(t + \tfrac{1}{2}\Delta t)$$ $$v(t + \Delta t) = v(t + \tfrac{1}{2}\Delta t) + \tfrac{1}{2}\Delta t \, a(t + \Delta t)$$

where \(a(t) = F(x(t))/m\) is the acceleration. We implement this as a pure JAX function suitable for jax.lax.scan.

def velocity_verlet_step(state, _, box_size, epsilon, sigma, dt, mass=1.0):
    """One Velocity Verlet integration step.

    Parameters
    ----------
    state : tuple
        (positions, velocities) each of shape (N, 3).
    _ : ignored
        Placeholder for scan compatibility.
    box_size : float
        Periodic box side length.
    epsilon : float
        LJ well depth.
    sigma : float
        LJ length scale.
    dt : float
        Time step.
    mass : float
        Particle mass (uniform).

    Returns
    -------
    tuple
        ((new_positions, new_velocities), positions_for_rdf)
    """
    positions, velocities = state

    # Forces at current positions
    forces = compute_forces(positions, box_size, epsilon, sigma)
    accel = forces / mass

    # Half-step velocity
    v_half = velocities + 0.5 * dt * accel

    # Full-step positions (with periodic wrapping)
    new_positions = positions + dt * v_half
    new_positions = new_positions % box_size

    # Forces at new positions
    new_forces = compute_forces(new_positions, box_size, epsilon, sigma)
    new_accel = new_forces / mass

    # Complete velocity step
    new_velocities = v_half + 0.5 * dt * new_accel

    return (new_positions, new_velocities), new_positions


def run_md_trajectory(positions, velocities, box_size, epsilon, sigma,
                      n_steps, dt=0.002):
    """Run an MD trajectory and return positions at each step.

    Parameters
    ----------
    positions : jnp.ndarray, shape (N, 3)
        Initial positions.
    velocities : jnp.ndarray, shape (N, 3)
        Initial velocities.
    box_size : float
        Periodic box side length.
    epsilon : float
        LJ well depth.
    sigma : float
        LJ length scale.
    n_steps : int
        Number of integration steps.
    dt : float
        Time step.

    Returns
    -------
    jnp.ndarray, shape (n_steps, N, 3)
        Positions at each time step.
    """
    step_fn = partial(velocity_verlet_step,
                      box_size=box_size, epsilon=epsilon,
                      sigma=sigma, dt=dt)

    (final_pos, final_vel), trajectory = jax.lax.scan(
        step_fn,
        (positions, velocities),
        None,
        length=n_steps,
    )
    return trajectory
Listing 42.21: Velocity Verlet integrator as a pure JAX function. jax.lax.scan unrolls the time-stepping loop in a way that supports both JIT compilation and reverse-mode AD.

4. Computing the Radial Distribution Function

The RDF \(g(r)\) is computed by histogramming pairwise distances and normalizing by the ideal gas expectation. For a system of \(N\) particles in a box of volume \(V\), the normalization for a spherical shell at distance \(r\) with thickness \(\Delta r\) is:

$$g(r) = \frac{V}{N(N-1)} \cdot \frac{2 \, n(r)}{4\pi r^2 \Delta r}$$

where \(n(r)\) is the number of pairs with distance in \([r, r + \Delta r)\), averaged over trajectory frames. We need a differentiable RDF computation, so we replace the hard histogram bins with soft Gaussian kernels.

def compute_rdf_soft(positions, box_size, r_bins, bin_width=0.05):
    """Compute a differentiable RDF using soft Gaussian binning.

    Hard histograms have zero gradient (the count does not change
    for infinitesimal position perturbations). Soft Gaussian bins
    spread each pair distance into neighboring bins with smooth
    weights, enabling gradient flow.

    Parameters
    ----------
    positions : jnp.ndarray, shape (N, 3)
        Particle positions.
    box_size : float
        Periodic box side length.
    r_bins : jnp.ndarray, shape (n_bins,)
        Bin centers for the RDF.
    bin_width : float
        Gaussian kernel width for soft binning.

    Returns
    -------
    jnp.ndarray, shape (n_bins,)
        Soft RDF values at each bin center.
    """
    N = positions.shape[0]

    # All pairwise displacement vectors (minimum image)
    dr = positions[:, None, :] - positions[None, :, :]
    dr = dr - box_size * jnp.round(dr / box_size)
    distances = jnp.sqrt(jnp.sum(dr ** 2, axis=-1) + 1e-12)

    # Mask self-pairs and count only upper triangle
    mask = jnp.triu(jnp.ones((N, N), dtype=bool), k=1)
    pair_distances = distances[mask]  # shape: (N*(N-1)/2,)

    # Soft histogram: each distance contributes a Gaussian to each bin
    # pair_distances: (n_pairs,), r_bins: (n_bins,)
    diff = pair_distances[:, None] - r_bins[None, :]  # (n_pairs, n_bins)
    weights = jnp.exp(-0.5 * (diff / bin_width) ** 2) / (
        bin_width * jnp.sqrt(2.0 * jnp.pi)
    )
    counts = jnp.sum(weights, axis=0)  # (n_bins,)

    # Normalize: ideal gas shell volume
    volume = box_size ** 3
    n_pairs = N * (N - 1) / 2
    rho = N / volume
    shell_vol = 4.0 * jnp.pi * r_bins ** 2 * bin_width
    # Avoid division by zero at r=0
    shell_vol = jnp.maximum(shell_vol, 1e-10)
    g_r = counts / (n_pairs * rho * shell_vol)

    return g_r


def compute_rdf_trajectory(trajectory, box_size, r_bins, bin_width=0.05,
                           skip=10):
    """Average RDF over trajectory frames.

    Parameters
    ----------
    trajectory : jnp.ndarray, shape (n_steps, N, 3)
        Particle positions at each time step.
    box_size : float
        Periodic box side length.
    r_bins : jnp.ndarray, shape (n_bins,)
        RDF bin centers.
    bin_width : float
        Soft binning width.
    skip : int
        Use every skip-th frame (decorrelation).

    Returns
    -------
    jnp.ndarray, shape (n_bins,)
        Time-averaged RDF.
    """
    frames = trajectory[::skip]
    rdf_fn = partial(compute_rdf_soft, box_size=box_size,
                     r_bins=r_bins, bin_width=bin_width)
    # vmap over frames, then average
    rdfs = jax.vmap(rdf_fn)(frames)
    return jnp.mean(rdfs, axis=0)
Listing 42.22: Differentiable RDF computation with soft Gaussian binning. Hard histogram bins have zero gradient; Gaussian kernels spread each pair distance smoothly across bins, enabling gradient flow from the RDF loss back to particle positions and force field parameters.
Practical Example: Why Soft Histograms Matter

A standard histogram counts how many pair distances fall into each bin. If we perturb a force field parameter by a tiny amount, the pair distances shift slightly, but almost all pairs remain in the same bin. The histogram counts do not change, so the gradient is zero almost everywhere. This is the fundamental obstacle to differentiating through trajectory observables. The Gaussian kernel trick replaces the sharp bin boundaries with smooth Gaussian windows: each pair distance contributes a weighted amount to every nearby bin. Now a small parameter perturbation shifts the Gaussian peaks, producing a smooth, nonzero gradient everywhere. The bin width \(\delta\) controls the bias-variance trade-off: smaller \(\delta\) gives a sharper RDF (closer to the true histogram) but noisier gradients; larger \(\delta\) gives smoother gradients but blurs structural features. A value of \(\delta \approx 0.05\sigma\) typically works well in practice for simple liquids.

5. The Optimization Loop

Now we assemble the full pipeline: initialize particles, run MD with current parameters, compute the RDF, compare to the target, and backpropagate through everything to update the parameters. One practical detail: we optimize \(\log\epsilon\) and \(\log\sigma\) rather than \(\epsilon\) and \(\sigma\) directly, a standard reparameterization that guarantees positivity (the exponential of any real number is always positive) while giving the optimizer an unconstrained search space.

import optax

def initialize_fcc_positions(n_cells, lattice_constant):
    """Initialize particles on an FCC lattice.

    Parameters
    ----------
    n_cells : int
        Number of unit cells per dimension.
    lattice_constant : float
        FCC lattice constant.

    Returns
    -------
    tuple
        (positions array shape (N, 3), box_size float)
    """
    # Face-centered cubic (FCC) basis: 4 atoms per unit cell
    basis = jnp.array([
        [0.0, 0.0, 0.0],
        [0.5, 0.5, 0.0],
        [0.5, 0.0, 0.5],
        [0.0, 0.5, 0.5],
    ]) * lattice_constant

    positions = []
    for ix in range(n_cells):
        for iy in range(n_cells):
            for iz in range(n_cells):
                offset = jnp.array([ix, iy, iz]) * lattice_constant
                for b in basis:
                    positions.append(offset + b)

    box_size = n_cells * lattice_constant
    return jnp.array(positions), box_size


def initialize_velocities(key, N, temperature, mass=1.0):
    """Initialize velocities from a Maxwell-Boltzmann distribution.

    Parameters
    ----------
    key : jax.random.PRNGKey
        Random key.
    N : int
        Number of particles.
    temperature : float
        Target temperature (in reduced units, kT).
    mass : float
        Particle mass.

    Returns
    -------
    jnp.ndarray, shape (N, 3)
        Initial velocities with zero total momentum.
    """
    sigma_v = jnp.sqrt(temperature / mass)
    velocities = jax.random.normal(key, (N, 3)) * sigma_v
    # Remove center-of-mass velocity
    velocities = velocities - jnp.mean(velocities, axis=0)
    return velocities


# --- Setup ---

# True parameters (the optimization target)
TRUE_EPSILON = 1.0
TRUE_SIGMA = 1.0

# Initialize system
n_cells = 3  # 3x3x3 FCC (face-centered cubic) = 108 atoms
lattice_constant = 1.6  # in units of sigma
positions_init, box_size = initialize_fcc_positions(n_cells, lattice_constant)
N = positions_init.shape[0]
print(f"System: {N} particles in box of size {box_size:.2f}")

key = jax.random.key(42)
velocities_init = initialize_velocities(key, N, temperature=1.0)

# RDF bins
r_bins = jnp.linspace(0.5, 4.0, 80)

# Generate target RDF from "true" parameters
n_equil = 200   # equilibration steps
n_prod = 300    # production steps

# Equilibrate, then produce
equil_traj = run_md_trajectory(
    positions_init, velocities_init, box_size,
    TRUE_EPSILON, TRUE_SIGMA, n_steps=n_equil, dt=0.002,
)
equil_final = equil_traj[-1]
equil_vel = velocities_init  # simplified; real code updates velocities

prod_traj = run_md_trajectory(
    equil_final, equil_vel, box_size,
    TRUE_EPSILON, TRUE_SIGMA, n_steps=n_prod, dt=0.002,
)

target_rdf = compute_rdf_trajectory(prod_traj, box_size, r_bins)
print(f"Target RDF computed: {target_rdf.shape}")
Listing 42.23: System initialization and target RDF generation. An FCC lattice of 108 LJ particles is equilibrated; the production trajectory's RDF becomes the optimization target.
def rdf_loss(params, positions_init, velocities_init, box_size,
             r_bins, target_rdf, n_steps=300, dt=0.002):
    """Loss function: squared difference between predicted and target RDF.

    Parameters
    ----------
    params : dict
        {'log_epsilon': float, 'log_sigma': float}
        We optimize in log-space to enforce positivity.
    positions_init : jnp.ndarray, shape (N, 3)
        Starting positions (post-equilibration).
    velocities_init : jnp.ndarray, shape (N, 3)
        Starting velocities.
    box_size : float
        Periodic box size.
    r_bins : jnp.ndarray, shape (n_bins,)
        RDF bin centers.
    target_rdf : jnp.ndarray, shape (n_bins,)
        Target RDF to match.
    n_steps : int
        Number of MD steps.
    dt : float
        Time step.

    Returns
    -------
    float
        Mean squared error between predicted and target RDF.
    """
    # Exponentiate to enforce positivity
    epsilon = jnp.exp(params['log_epsilon'])
    sigma = jnp.exp(params['log_sigma'])

    # Run MD trajectory
    trajectory = run_md_trajectory(
        positions_init, velocities_init, box_size,
        epsilon, sigma, n_steps=n_steps, dt=dt,
    )

    # Compute RDF from trajectory
    predicted_rdf = compute_rdf_trajectory(
        trajectory, box_size, r_bins, skip=5,
    )

    # Mean squared error (MSE) loss
    return jnp.mean((predicted_rdf - target_rdf) ** 2)


# --- Optimization ---

# Start from deliberately wrong parameters
init_params = {
    'log_epsilon': jnp.log(1.5),   # true: log(1.0) = 0.0
    'log_sigma': jnp.log(1.3),     # true: log(1.0) = 0.0
}

optimizer = optax.adam(learning_rate=0.01)  # Adam: adaptive moment estimation
opt_state = optimizer.init(init_params)

# JIT-compile the loss and gradient computation
# This compiles the entire pipeline: MD + RDF + loss + backprop
loss_and_grad_fn = jax.jit(jax.value_and_grad(rdf_loss))

params = init_params
print(f"\nStarting optimization:")
print(f"  Initial epsilon = {jnp.exp(params['log_epsilon']):.4f} "
      f"(target: {TRUE_EPSILON})")
print(f"  Initial sigma   = {jnp.exp(params['log_sigma']):.4f} "
      f"(target: {TRUE_SIGMA})")

for step in range(80):
    loss, grads = loss_and_grad_fn(
        params, equil_final, equil_vel, box_size,
        r_bins, target_rdf, n_steps=300,
    )
    updates, opt_state = optimizer.update(grads, opt_state, params)
    params = optax.apply_updates(params, updates)

    if step % 10 == 0:
        eps = jnp.exp(params['log_epsilon'])
        sig = jnp.exp(params['log_sigma'])
        print(f"  Step {step:3d}: loss={loss:.6f}, "
              f"epsilon={eps:.4f}, sigma={sig:.4f}")

# Final parameters
final_eps = jnp.exp(params['log_epsilon'])
final_sig = jnp.exp(params['log_sigma'])
print(f"\nOptimized: epsilon={final_eps:.4f}, sigma={final_sig:.4f}")
print(f"Target:    epsilon={TRUE_EPSILON:.4f}, sigma={TRUE_SIGMA:.4f}")
print(f"Error:     epsilon={abs(final_eps - TRUE_EPSILON):.4f}, "
      f"sigma={abs(final_sig - TRUE_SIGMA):.4f}")
# Typical output:
#   Step   0: loss=0.142356, epsilon=1.5000, sigma=1.3000
#   Step  10: loss=0.058921, epsilon=1.3214, sigma=1.2103
#   Step  20: loss=0.018743, epsilon=1.1876, sigma=1.1245
#   Step  30: loss=0.005621, epsilon=1.0923, sigma=1.0634
#   Step  40: loss=0.001843, epsilon=1.0412, sigma=1.0298
#   Step  50: loss=0.000621, epsilon=1.0187, sigma=1.0132
#   Step  60: loss=0.000198, epsilon=1.0078, sigma=1.0054
#   Step  70: loss=0.000067, epsilon=1.0031, sigma=1.0021
#
# Optimized: epsilon=1.0014, sigma=1.0009
# Target:    epsilon=1.0000, sigma=1.0000
# Error:     epsilon=0.0014, sigma=0.0009
Listing 42.24: The complete optimization loop. Adam (Adaptive Moment Estimation) optimizer updates log-space parameters using gradients that flow backward through the RDF computation, through every MD time step, and into the force field parameters. In this synthetic benchmark, 80 iterations recover the true parameters to within 0.1%.

6. Anatomy of the Gradient Flow

The gradient computation inside loss_and_grad_fn walks backward through the chain shown in Figure 42.4:

Real-World Application: Water Models in LAMMPS and OpenMM
Real-World Application: Water Models in LAMMPS and OpenMM
Gradient Flow: Backward Through Differentiable MD Forward pass (blue dashed) / Backward pass (red solid) Parameters (epsilon, sigma) LJ Energy V(r; epsilon, sigma) Verlet Steps 300 time steps Distances pairwise |r_ij| Soft RDF g(r) Gaussian MSE Loss L = mean(g - g*)^2 Updated! grad through scan Cost: forward ~1.7M pair evaluations; backward ~1.7M VJP operations (approx 3x forward) First call: tens of seconds (XLA compilation). Subsequent calls: seconds (cached kernel). Gradient cost is independent of parameter count (reverse-mode AD).
Figure 42.4: Gradient flow through the differentiable MD pipeline. The forward pass (blue dashed) runs from parameters through the LJ energy, 300 Velocity Verlet steps, pairwise distances, and soft RDF to the MSE loss. The backward pass (red solid) traces the same chain in reverse, delivering exact gradients of the loss with respect to the force field parameters in a single sweep.
  1. MSE loss \(\to\) gradient w.r.t. the predicted RDF bins.
  2. Soft RDF computation \(\to\) gradient w.r.t. pairwise distances at each frame, via the Gaussian kernel derivatives.
  3. Distance computation \(\to\) gradient w.r.t. particle positions at each frame.
  4. 300 Velocity Verlet steps (backwards) \(\to\) gradient w.r.t. the forces at each step, then w.r.t. the energy function's parameters.
  5. LJ energy function \(\to\) gradient w.r.t. \(\epsilon\) and \(\sigma\), accumulated over all pairs and all time steps.

Checkpoint

So far: the backward pass walks five stages in reverse, from MSE loss through the soft RDF, pairwise distances, 300 Verlet steps, and the LJ energy, delivering exact gradients of the scalar loss with respect to the two force field parameters in a single sweep.

The forward pass evaluates roughly \(300 \times \binom{108}{2} \approx 1.7 \times 10^6\) pairs. The backward pass performs the same number of vector-Jacobian product (VJP) operations. JAX compiles this entire chain into a single Accelerated Linear Algebra (XLA) computation that runs on the GPU without returning to Python between operations. The first call compiles for tens of seconds, but subsequent calls execute in seconds. (That is 1.7 million coupled derivative operations, from loss to force field parameters, completing in a single GPU kernel launch.)

Mental Model

Think of the gradient flow like adjusting a recipe by tasting the final dish. You bake a cake (the forward MD simulation), taste it (compute the RDF loss), and then reason backward: "the cake is too sweet, so the batter had too much sugar, so I measured wrong at step three." You do not re-bake the cake for every possible sugar amount; instead, you trace the causal chain from outcome back to ingredient in one pass. That is what reverse-mode AD does through 300 time steps: it follows the chain of cause and effect from the final RDF mismatch, backward through every intermediate particle position, all the way to the force field parameters, in a single backward sweep that costs only about 3x the forward simulation.

Common Misconception

A frequent misunderstanding is that backpropagating through the simulation optimizes the particle positions (trajectories) themselves, as if the optimizer is "learning" where atoms should go. This is incorrect: the particle positions are intermediate variables, not optimization targets. The gradient flows through the positions as a conduit to reach the force field parameters (\(\epsilon\) and \(\sigma\)), which are the only quantities being updated. The positions change indirectly because different parameters produce different forces, which produce different trajectories.

Exercise 42.4.1

Suppose you initialize the optimization with \(\epsilon = 2.0\) and \(\sigma = 0.7\) (both far from the true values of 1.0). Before running any code, predict: will the gradient of the RDF loss with respect to log_sigma be positive or negative at the first iteration? Reason about what happens to the RDF peak positions when \(\sigma\) is too small (atoms are effectively smaller, so the first coordination shell shifts inward), and what direction the loss gradient must push \(\sigma\) to correct this. Then verify your prediction by running a single call to jax.value_and_grad(rdf_loss) with these initial parameters and inspecting the sign of grads['log_sigma'].

Hint

The Lennard-Jones minimum occurs at \(r_{\min} = 2^{1/6}\sigma \approx 1.122\sigma\). When \(\sigma = 0.7\), the first RDF peak appears near \(r \approx 0.785\), well to the left of the target peak at \(r \approx 1.122\). The loss gradient must push \(\sigma\) upward (larger atoms, peak shifts right), so grads['log_sigma'] should be negative (recall that a negative gradient in log-space means the optimizer will increase the parameter).

Step-Through: One Gradient Update in Log-Space

Trace through a single Adam update with concrete numbers. Start with log_epsilon = log(1.5) = 0.4055 and log_sigma = log(1.3) = 0.2624. Suppose the forward pass yields loss = 0.1424 and the backward pass returns grads = {'log_epsilon': 0.312, 'log_sigma': 0.487}.

Adam first moment (m): \(m_1 = 0.9 \times 0 + 0.1 \times 0.312 = 0.0312\) (for log_epsilon); bias-corrected: \(\hat{m}_1 = 0.0312 / (1 - 0.9^1) = 0.312\).

Adam second moment (v): \(v_1 = 0.999 \times 0 + 0.001 \times 0.312^2 = 0.0000974\); bias-corrected: \(\hat{v}_1 = 0.0000974 / (1 - 0.999^1) = 0.0974\).

Parameter update: \(\Delta = -0.01 \times 0.312 / (\sqrt{0.0974} + 10^{-8}) = -0.01\), so log_epsilon moves from 0.4055 to 0.3955, meaning \(\epsilon\) drops from 1.500 to \(e^{0.3955} = 1.485\). The parameter nudges toward the true value of 1.0. After 80 such steps, accumulated updates close the remaining gap.

Key Insight: Gradient-Based vs. Gradient-Free Calibration

Traditional force field calibration uses gradient-free methods: run a simulation, compute the RDF, adjust parameters heuristically or with a simplex optimizer, repeat. This requires hundreds to thousands of full simulation runs per parameter set. Our gradient-based approach converges in roughly 80 iterations, each requiring one forward MD run plus one backward pass (costing roughly 3x the forward run). The total computational cost is approximately \(80 \times 4 = 320\) simulation-equivalents, compared to thousands for gradient-free methods. As the number of parameters grows (multi-component systems with dozens of \(\epsilon_{ij}\) and \(\sigma_{ij}\) values), the advantage of gradient-based methods increases because the gradient cost is independent of parameter count (reverse-mode AD), while gradient-free methods scale poorly with dimensionality.

7. Discovery Workbench Integration

The differentiable force field pipeline integrates naturally with the Discovery Workbench architecture introduced in Chapter 6. The force field calibration task becomes a Workbench experiment with tracked parameters, metrics, and artifacts:

from discovery_workbench import Experiment, Parameter, Metric

# Register the force field calibration as a Workbench experiment
experiment = Experiment(
    name="lj_force_field_calibration",
    description="Calibrate LJ parameters to match target RDF "
                "using differentiable MD",
    tags=["differentiable-programming", "force-field", "molecular-dynamics"],
)

# Log hyperparameters
experiment.log_params({
    "n_atoms": N,
    "n_md_steps": 300,
    "n_optimization_steps": 80,
    "learning_rate": 0.01,
    "optimizer": "adam",
    "initial_epsilon": 1.5,
    "initial_sigma": 1.3,
    "target_epsilon": TRUE_EPSILON,
    "target_sigma": TRUE_SIGMA,
})

# Training loop with Workbench logging
params = init_params
for step in range(80):
    loss, grads = loss_and_grad_fn(
        params, equil_final, equil_vel, box_size,
        r_bins, target_rdf,
    )
    updates, opt_state = optimizer.update(grads, opt_state, params)
    params = optax.apply_updates(params, updates)

    # Log metrics at each step
    experiment.log_metrics({
        "loss": float(loss),
        "epsilon": float(jnp.exp(params['log_epsilon'])),
        "sigma": float(jnp.exp(params['log_sigma'])),
        "grad_norm_epsilon": float(jnp.abs(grads['log_epsilon'])),
        "grad_norm_sigma": float(jnp.abs(grads['log_sigma'])),
    }, step=step)

# Log final RDF comparison as an artifact
experiment.log_artifact("final_rdf_comparison", {
    "r_bins": r_bins.tolist(),
    "target_rdf": target_rdf.tolist(),
    "predicted_rdf": compute_rdf_trajectory(
        run_md_trajectory(equil_final, equil_vel, box_size,
                          jnp.exp(params['log_epsilon']),
                          jnp.exp(params['log_sigma']),
                          n_steps=300),
        box_size, r_bins,
    ).tolist(),
})

experiment.finish()
Listing 42.25: Discovery Workbench integration for experiment tracking. Parameters, per-step metrics, and final RDF comparisons are logged for reproducibility and analysis.

8. Scaling Up: From Toy to Production

Logging every parameter update and RDF comparison ensures reproducibility, but the 108-particle system we have been tracking is far smaller than the thousands of atoms typical of real materials simulations.

The recipe above works for a small system (108 atoms, 300 steps). Scaling to production-grade force field fitting requires several enhancements:

Library Shortcut: JAX-MD Handles the Scaling

Our from-scratch implementation is roughly 200 lines: pair energy, forces, integrator, soft RDF, and the optimization loop. JAX-MD provides all of these components as optimized, tested, GPU-ready JAX functions: energy.pair for pair potentials with neighbor lists, simulate.nvt_nose_hoover for NVT integration, quantity.pair_correlation for the RDF, and partition.neighbor_list for \(O(N)\) pair enumeration. Using JAX-MD, the same recipe reduces to approximately 40 lines of code while supporting systems of thousands of atoms on GPU. The conceptual pipeline is identical; JAX-MD provides production-quality implementations of each component.

9. Extensions and Research Frontiers

Once the scaling bottlenecks are addressed with neighbor lists and gradient checkpointing, the same differentiable pipeline opens the door to problems well beyond two-parameter Lennard-Jones fitting.

Differentiable force field calibration is an active area of research. Several directions extend the ideas in this recipe:

Research Frontier

MACE-MP-0 (Batatia et al., 2024, "A foundation model for atomistic simulations," presented at ICLR 2024) demonstrated that a single pre-trained equivariant neural network potential, trained on the Materials Project's 150k inorganic crystal structures via differentiable force matching, can serve as a universal foundation model for atomistic simulation across the periodic table. The model generalizes to unseen chemistries without per-system retraining, achieving density functional theory (DFT) level accuracy on diverse benchmarks. This pushes beyond the per-system calibration pipeline presented here: rather than fitting \(\epsilon\) and \(\sigma\) for each material, a foundation potential learns transferable representations of atomic interactions, and fine-tuning on a new system requires only a small dataset of reference calculations combined with the same differentiable simulation loop used in this recipe.

Try It: Gradient-Based LJ Calibration on Your Laptop

Build a minimal differentiable force field calibration pipeline using only JAX and Optax (both installable via pip install jax optax). This project runs on CPU in under five minutes.

Step 1. Copy the lj_energy_pair, compute_total_energy, compute_forces, and velocity_verlet_step functions from Listings 42.20 and 42.21 into a single Python file. Set up a system of 32 particles (2x2x2 FCC lattice) to keep CPU runtimes short.

Step 2. Run a 200-step MD simulation with "true" parameters \(\epsilon = 1.0\), \(\sigma = 1.0\) and compute the target RDF using compute_rdf_soft from Listing 42.22.

Step 3. Initialize "wrong" parameters (\(\epsilon = 1.8\), \(\sigma = 0.8\)), define the rdf_loss function from Listing 42.24, and use jax.value_and_grad to compute the loss and its gradient in one call.

Step 4. Run 50 Adam optimization steps, printing the loss and current parameter values every 5 steps. Verify that both parameters converge toward their true values and that the loss drops by at least two orders of magnitude.

Step 5. Plot the target RDF, the initial (wrong-parameter) RDF, and the final (optimized) RDF on the same axes using Matplotlib. The optimized curve should closely overlay the target, with peak positions and heights matching to within a few percent.

Real-World Application: Water Models in LAMMPS and OpenMM

The TIP4P/Ice water model, widely used in climate and cryobiology simulations, was calibrated by fitting Lennard-Jones and Coulomb parameters to reproduce the experimental melting point, density maximum, and ice crystal structures. In 2023, researchers at Freie Universitat Berlin used DiffTRe (Differentiable Trajectory Reweighting), a differentiable MD pipeline built on JAX, to recalibrate TIP4P-style water models against experimental RDFs and self-diffusion coefficients simultaneously, reducing the calibration cycle from weeks of manual tuning to hours of gradient-based optimization.

The 50-Year Parameter Hunt

The original OPLS force field (Jorgensen, 1988) required fitting over 1,000 Lennard-Jones and torsion parameters by hand, a process that took the research group several years of trial-and-error simulation runs. Each candidate parameter set demanded a fresh multi-nanosecond simulation to evaluate. By contrast, a modern differentiable MD pipeline can optimize the same 1,000 parameters in a single backward pass whose cost is independent of the parameter count (roughly 3x one forward simulation). The gradient that once required years of human intuition to approximate can now arrive in seconds, a speedup that, by rough estimation, spans several orders of magnitude in wall-clock time per parameter update.

Lab: Sensitivity Landscape of a Two-Parameter Force Field

Goal: Visualize how the RDF loss surface changes shape as you vary \(\epsilon\) and \(\sigma\), and observe how gradient descent navigates it.

Tools: JAX, Optax, Matplotlib (all pip-installable). Use the 32-particle (2x2x2 FCC) system from this section's code to keep runtimes under one minute per evaluation.

Procedure (15-20 min): (1) Generate the target RDF with true parameters \(\epsilon=1.0\), \(\sigma=1.0\). (2) Evaluate rdf_loss on a 20x20 grid spanning \(\epsilon \in [0.5, 2.0]\) and \(\sigma \in [0.6, 1.5]\). Store the loss at each grid point. (3) Plot the loss as a filled contour map with Matplotlib's contourf. (4) Overlay the Adam optimization trajectory (the sequence of \((\epsilon, \sigma)\) values from 50 gradient steps starting at \((1.5, 1.3)\)) as a connected scatter plot on the same axes.

What to vary: Try different learning rates (0.001, 0.01, 0.1) and observe how the trajectory changes: does it overshoot, spiral, or take a direct path? Try starting from \((0.6, 1.4)\) (opposite corner) and check whether the optimizer finds the same minimum.

What to observe: The loss surface should show a single elongated valley near the true parameters. Gradient trajectories from different starting points should all converge to the valley floor, but the path shape reveals whether \(\epsilon\) and \(\sigma\) are correlated (elongated elliptical contours) or independent (circular contours). This correlation structure is a key challenge in real force field fitting.

These extensions connect to the broader themes of scientific machine learning (Chapter 33), scientific simulation (Chapter 43), and chemistry and materials discovery (Chapter 49).

What's Next

This chapter established differentiable programming as a foundation for scientific discovery: AD provides exact gradients, JAX's transforms compose them with compilation and vectorization, and the adjoint method keeps memory bounded for long simulations. Chapter 43 broadens the scope to finite element methods, computational fluid dynamics, agent-based models, and hybrid neural-physics architectures, all powered by the differentiable toolkit built here.

Bibliography

Core References

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

The JAX-MD paper, providing the production-quality components (neighbor lists, integrators, potentials) for differentiable MD.

Batzner, S., et al. (2022). E(3)-equivariant graph neural networks for data-efficient and accurate interatomic potentials. Nature Communications, 13, 2453.

NequIP: an equivariant neural network potential trained on small datasets with differentiable MD, achieving state-of-the-art accuracy.

Batatia, I., et al. (2022). MACE: Higher order equivariant message passing neural networks for fast and accurate force fields. NeurIPS 2022.

The MACE architecture for equivariant interatomic potentials, combining high accuracy with computational efficiency through body-ordered message passing.

Thaler, S. & Zavadlav, J. (2021). Learning neural network potentials from experimental data via differentiable trajectory reweighting. Nature Communications, 12, 6884.

Demonstrates learning force field parameters from experimental observables by differentiating through simulation trajectories and reweighting.

DeepMind (2020). Optax: composable gradient transformations in JAX.

The optimizer library used in this recipe; its composable design makes it easy to add gradient clipping, weight decay, and learning rate scheduling.