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)
vmap over pairs, and forces via jax.grad of the energy. The force computation is automatic: no hand-derived force expressions needed.
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
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)
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}")
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
6. Anatomy of the Gradient Flow
The gradient computation inside loss_and_grad_fn walks backward through the chain shown in Figure 42.4:
- MSE loss \(\to\) gradient w.r.t. the predicted RDF bins.
- Soft RDF computation \(\to\) gradient w.r.t. pairwise distances at each frame, via the Gaussian kernel derivatives.
- Distance computation \(\to\) gradient w.r.t. particle positions at each frame.
- 300 Velocity Verlet steps (backwards) \(\to\) gradient w.r.t. the forces at each step, then w.r.t. the energy function's parameters.
- 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.
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()
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:
- Neighbor lists: our \(O(N^2)\) pair enumeration must be replaced with neighbor lists for \(O(N)\) scaling. JAX-MD provides differentiable neighbor lists that handle periodic boundaries and dynamic allocation.
- Gradient checkpointing: for \(> 10^3\) MD steps, use
jax.checkpointon the scan body (as shown in Listing 42.18) to bound memory usage. - Multiple observables: real force field fitting matches not just the RDF but also the mean potential energy, pressure, diffusion coefficient, and possibly forces from quantum mechanical reference calculations. Each observable adds a term to the loss; gradients through all terms come from a single backward pass.
- Multiple state points: a robust force field must reproduce properties at multiple temperatures and densities. Use
vmapacross state points to run simulations in parallel and average gradients. - Neural network potentials: replace the two-parameter LJ function with a neural network (as in Listing 42.11) that takes atomic environment descriptors as input. The same optimization pipeline works; only the energy function and its parameter count change.
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:
- Differentiable free energy: matching free energies (not just structural observables) requires differentiating through thermodynamic integration or alchemical transformations (computational techniques that gradually morph one molecule into another to estimate free energy differences). Recent work (Wirnsberger et al., 2022) shows this is feasible with normalizing flows.
- Multi-fidelity optimization: combine cheap short simulations (for gradient direction) with expensive long simulations (for accurate observables) in a multi-fidelity optimization scheme. The short simulations provide biased but low-variance gradients; the long simulations correct the bias periodically.
- Equivariant neural potentials: models like NequIP, MACE, and Allegro use \(E(3)\)-equivariant (invariant to rotations, reflections, and translations in 3D space) neural networks to learn potentials that respect rotational symmetry by construction. Training these models through differentiable MD is the current state of the art for machine-learned interatomic potentials.
- Coarse-grained model discovery: fit coarse-grained (simplified representations where groups of atoms are merged into single interaction sites) force fields to reproduce structural and dynamical properties of atomistic simulations, automating the coarse-graining process that traditionally requires expert physical intuition.
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).
Bibliography
Core References
The JAX-MD paper, providing the production-quality components (neighbor lists, integrators, potentials) for differentiable MD.
NequIP: an equivariant neural network potential trained on small datasets with differentiable MD, achieving state-of-the-art accuracy.
The MACE architecture for equivariant interatomic potentials, combining high accuracy with computational efficiency through body-ordered message passing.
Demonstrates learning force field parameters from experimental observables by differentiating through simulation trajectories and reweighting.
The optimizer library used in this recipe; its composable design makes it easy to add gradient clipping, weight decay, and learning rate scheduling.