Enhanced Sampling¶
This library supports two enhanced sampling methods,
Metadynamics, and
Free Energy Perturbation (FEP),
both using the JAX-MD simulation backend.
Metadynamics¶
Metadynamics is an enhanced-sampling technique that adds a history-dependent
bias potential along one or more collective variables (CVs) to help the system
escape free-energy basins and explore configuration space more efficiently.
The implemented variant is well-tempered metadynamics
(Barducci et al., PRL 2008):
Gaussian hills are deposited periodically and their heights are rescaled by a
factor that depends on the accumulated bias, preventing the bias from growing
without bound. Setting bias_factor=None disables the rescaling and recovers
plain (untempered) metadynamics, equivalent to the γ → ∞ limit.
The
JaxMDMetadynamicsSimulationEngine
extends the JAX-MD simulation engine and is configured via
MetadynamicsConfig
embedded inside
JaxMDMetadynamicsSimulationConfig.
Note that batched simulations with metadynamics are not currently supported.
For a worked end-to-end example, we refer to our metadynamics tutorial notebook.
Minimal example (distance CV, upper wall):
from ase.io import read as ase_read
from mlip.simulation.metadynamics.jax_md_metad_engine import (
JaxMDMetadynamicsSimulationEngine,
)
from mlip.simulation.metadynamics.config import MetadynamicsConfig
from mlip.simulation.metadynamics.potential_terms import (
DistanceCVConfig,
DistanceWallConfig,
)
from mlip.simulation.enums import SimulationType, MDIntegrator
atoms = ase_read("/path/to/structure.xyz")
force_field = _get_a_trained_force_field_from_somewhere() # placeholder
metad_config = MetadynamicsConfig(
bias_cvs=[DistanceCVConfig(atom_indices_1=[10], atom_indices_2=[30])],
bias_sigmas=[0.2], # Å
bias_factor=15.0, # well-tempered γ
deposition_interval=500, # steps between hill depositions
initial_height=0.02, # eV
walls=[
DistanceWallConfig(
atom_indices_1=[10], atom_indices_2=[30], upper=3.5, kappa=50.0, exp=2
)
],
)
engine_config = JaxMDMetadynamicsSimulationEngine.Config(
metadynamics_config=metad_config,
simulation_type=SimulationType.MD,
md_integrator=MDIntegrator.NVT_LANGEVIN,
num_steps=500_000,
snapshot_interval=10,
num_episodes=500,
timestep_fs=1.0,
temperature_kelvin=300.0,
)
engine = JaxMDMetadynamicsSimulationEngine(atoms, force_field, engine_config)
engine.run()
state = engine.state
print(state.bias_cv_values.shape) # (n_snapshots, num_cvs) — bias CV trajectory
print(state.bias_potential.shape) # (n_snapshots,) — bias energy at each snapshot
Collective variables¶
One or more collective variables (CVs) can be included in the bias potential via the
bias_cvs list; the corresponding bias_sigmas list must have the same length. Note
that it is recommended to use 1-2 CVs as the volume of CV space grows exponentially
with dimensionality, making it harder to convergence.
Collective variables are specified using their config classes:
import math
from mlip.simulation.metadynamics.potential_terms import (
AngleCVConfig,
DistanceCVConfig,
)
bias_cvs = [
DistanceCVConfig(atom_indices_1=[10], atom_indices_2=[30]),
AngleCVConfig(atom_indices=[5, 10, 30]),
]
The available CV config classes are:
Config class |
Description |
|---|---|
Pairwise distance between the centroids of two atom groups |
|
Bond angle for a triplet p–q–r where q is the vertex (radians). Set |
|
Dihedral angle for a quadruplet i–j–k–l. Set |
|
Differentiable coordination number of a central atom with respect to a
neighbor element, computed via a rational switching function. Set
|
Walls¶
Wall potentials confine a CV to a desired range without affecting the bias.
They are one-sided potentials: V = kappa * max(s - upper, 0)^exp or
V = kappa * max(lower - s, 0)^exp. Pass a list of wall configs via walls:
import math
from mlip.simulation.metadynamics.potential_terms import (
AngleWallConfig,
DistanceWallConfig,
)
walls = [
# Keep distance (atoms 10, 30) below 3.5 Å
DistanceWallConfig(
atom_indices_1=[10], atom_indices_2=[30], upper=3.5, kappa=50.0
),
# Keep angle (atoms 5, 10, 30) above 80° and below 150°
AngleWallConfig(
atom_indices=(5, 10, 30),
lower_rad=math.radians(80.0),
upper_rad=math.radians(150.0),
kappa=100.0,
),
]
Available wall config classes:
Config class |
Description |
|---|---|
Wall potential on a distance CV (Å). Accepts |
|
Wall potential on a bond-angle CV. Thresholds set in radians via |
Positional restraints¶
Positional restraints apply a harmonic penalty V = 0.5 * kappa * Σ |r_i - r0_i|²
to keep a set of atoms near their initial positions. This is useful for example to keep a
solvent shell or spectator atoms from drifting while a reactive fragment is
biased. Pass a list of
PositionalRestraintConfig
objects via restraints:
from mlip.simulation.metadynamics.potential_terms import PositionalRestraintConfig
restraints = [
# Restrain atoms 0–19 with kappa = 100 eV/Ų
PositionalRestraintConfig(atom_indices=list(range(20)), kappa=100.0)
]
Alternatively, set start_atom_index instead of atom_indices and the engine
will automatically identify the restrained fragment via BFS over an implicit
bond graph (added for all pairs with distance 0.1–1.8 Å) starting from start_atom_index.
Simulation state¶
After running, engine.state is a
MetadynamicsSimulationState
that extends the standard
SimulationState with:
Field |
Description |
|---|---|
|
Values of the CVs used by the bias potential at each logged snapshot. |
|
Total bias energy (eV) at each logged snapshot. |
|
Positions of all deposited Gaussian hills along the bias potential CVs. |
|
Heights (eV) of all deposited Gaussian hills. |
FEP Simulations¶
Free Energy Perturbation (FEP) is a statistical mechanics method used to compute the free-energy difference between two “end-states” of a system. Instead of simulating physical pathways, it relies on “alchemical” transitions, such as artificially vanishing a solute so it is fully decoupled from its solvent, or morphing one molecule into another while holding the surrounding environment fixed (Zwanzig, R. W. JCP 1954, for a modern review Mey, Antonia SJ et al., Liv. J. Comput. Mol. Sci. 2020).
FEP utilizes a series of simulations mapped along a non-physical alchemical path between two end-states. By using auxiliary “lambda” (\(\lambda\)) parameters to scale the system’s Hamiltonian, we smoothly transition between these states computationally. The total free-energy difference is then recovered by pooling the energy changes sampled at each intermediate lambda window.
Our current implementation supports a single alchemical transformation pathway: the gradual decoupling of a selected set of atoms from the rest of the system. This is achieved by scaling the interaction edges of the MLIP graph. To transition from the fully interacting (State A) to the fully decoupled (State B) state, as required for solvation free energy calculations, we employ a three-stage pathway (\(A \to R \to B\)) inspired by Xie et al., JCTC 2026:
State A (Fully Coupled): The target atoms interact normally with their surroundings.
State R (Repulsive): Directly removing interaction edges can allow clashes between atoms before the edge is completely removed, which can cause spikes in the MLIP energy. To prevent this, we introduce a softcore repulsive potential along the alchemical path to keep the two subsystems physically separated while the MLIP graph edges are being scaled down, which is at maximal strength at this intermediate stage.
State B (Fully Decoupled): The target atoms are entirely isolated from the rest of the system.
In practice, the calculation is performed by running multiple parallel replicas of the system, each mapped to a specific coordinate (a “lambda window”) along this path. The final free energy is then estimated from the potential energy fluctuations between adjacent windows using estimators like BAR (Bennett C.H., JCP 1976) or MBAR (Shirts, M. R., & Chodera, J. D. JCP 2006,).
The
FEPSimulationSampler
orchestrates one JAX-MD simulation engine per lambda window, dispatches them
in parallel across local devices (and across hosts in multi-host TPU
deployments), and optionally performs Hamiltonian Replica Exchange (HREX)
between adjacent windows. It is configured via
FEPSimulationSamplerConfig,
which embeds a standard
JaxMDSimulationConfig
describing the MD settings shared by every window.
Note that, unlike the batched simulations described in simulation section, each lambda window runs a single (non-batched) system; the parallelism here is across lambda windows rather than across independent systems in one batch. Also note that Hessian predictions are not supported when running FEP simulations.
For a worked end-to-end example, we refer to our FEP tutorial notebook.
Minimal example (hydration free energy of a solute in a water box):
from ase.io import read as ase_read
import numpy as np
from mlip.simulation.fep.sampler import FEPSimulationSampler
from mlip.simulation.configs.jax_md_config import JaxMDSimulationConfig
from mlip.simulation.enums import SimulationType, MDIntegrator
atoms = ase_read("/path/to/solute_in_water_box.pdb")
molecule_indices = np.load("path/to/system_molecule_indices.npy")
force_field = _get_a_trained_force_field() # placeholder
# The first 20 atoms are the solute; these are the atoms to be decoupled
# from the rest of the system (the solvent).
alchemical_atom_indices = list(range(20))
# Lambda schedule along the path:
# A (edge=1, rep=0) -> R (edge=0, rep=1) -> B (edge=0, rep=0)
lambda_edge_values = [1.00, 0.71, 0.43, 0.14, 0.00, 0.00, 0.00, 0.00, 0.00]
lambda_repulsion_values = [0.00, 0.29, 0.57, 0.86, 1.00, 0.75, 0.50, 0.25, 0.00]
engine_config = JaxMDSimulationConfig(
simulation_type=SimulationType.MD,
md_integrator=MDIntegrator.NPT_MC_LANGEVIN,
num_steps=270_000,
num_episodes=540,
snapshot_interval=50,
timestep_fs=1.0,
temperature_kelvin=298.15,
molecule_indices=molecule_indices,
pressure_bar=1.01325,
barostat_update_interval=25,
)
sampler_config = FEPSimulationSampler.Config(
simulation_config=engine_config,
alchemical_atom_indices=alchemical_atom_indices,
lambda_edge_values=lambda_edge_values,
lambda_repulsion_values=lambda_repulsion_values,
use_alchemical_mlip=True,
use_replica_exchange=True,
num_equilibration_episodes=40,
checkpoint_interval_episodes=20,
)
sampler = FEPSimulationSampler(atoms, force_field, sampler_config)
sampler.run()
Note that in the example above, _get_a_trained_force_field() is a placeholder
for a function that loads a trained force field, as described either
here (Option 1) or here (Option 2).
Instead of a single ase.Atoms object, one may also pass a list with one
entry per lambda window (all describing the same system, e.g., differing only
in starting positions or velocities) to FEPSimulationSampler.
Alchemical atoms and the lambda schedule¶
alchemical_atom_indices selects the atoms to be treated as alchemical
(typically the solute). Edges connecting these atoms to the rest of the
system are the ones gradually switched off along the simulation.
lambda_edge_values and lambda_repulsion_values are equal-length lists, one
entry per lambda window, where:
lambda_edge_valuesis the edge weight, scaling the message-passing edges between alchemical and non-alchemical atoms (1.0 = fully connected, 0.0 = fully decoupled).lambda_repulsion_valuesis the repulsive potential weight, scaling the strength of the softcore repulsive potential between alchemical and non-alchemical atoms. This is used to prevent clashes while the alchemical atoms are not fully decoupled.
Computing the alchemical MLIP potential¶
For cases where the edges of the MLIP graph are partially on, it is necessary
to compute the “alchemical potential” using a pretrained MLIP model. There are
two ways to compute the alchemical energy/forces from a trained MLIP, controlled
by use_alchemical_mlip on
FEPSimulationSamplerConfig:
Native alchemical MLIP (
use_alchemical_mlip=True, default): the edge weight is applied directly inside the model’s message-passing equations to compute an alchemical energy with no computational overhead (see Moore et al., arXiv 2024 and Nam et al., arXiv 2024). We provide alchemical variants of all four supported architectures in our library, which can be used directly with any pretrained model. Note that this approach results in a non-linear relationship between the edge weight and the alchemical potential, which may make the lambda schedule more difficult to tune.Linear combination (
use_alchemical_mlip=False): duplicates the graph into its two end-states (fully-coupled and fully-decoupled) and batches them together before the forward pass; the two resulting energies are then linearly combined, weighted by the edge weight. This works with anyMLIPNetwork, including legacy v1 checkpoints, at the cost of a more expensive forward pass. For lambda values where the graph is either fully-coupled or fully-decoupled (i.e. for edge weights1.0or0.0), we do not duplicate the graph before the forward pass. Note that this approach results in a linear relationship between the edge weight and the alchemical potential, which may make the lambda schedule easier to tune.
FEPSimulationSampler converts the standard base input force field into the requested
alchemical variant automatically, depending on use_alchemical_mlip.
Softcore repulsive potentials¶
While the edge weight is being ramped down, a softcore repulsive potential
between alchemical and non-alchemical atoms is ramped up (via the repulsive
potential weight) to prevent the decoupled solute from overlapping with its
environment. Two variants are available, both parameterized per-element via
sigma_map / epsilon_map (defaulting to representative GAFF-2.1 values):
Config class |
Description |
|---|---|
|
Softcore Lennard-Jones potential (default). |
|
Softcore Weeks–Chandler–Andersen potential (default): a repulsion-only equivalent of the softcore Lennard-Jones potential; shifted and truncated to remove the attractive region. |
Pass an instance via the repulsive_potential attribute of
FEPSimulationSamplerConfig:
from mlip.fep.models import SoftcoreWCAPotential
sampler_config = FEPSimulationSampler.Config(
repulsive_potential=SoftcoreWCAPotential(),
**config_kwargs,
)
Hamiltonian replica exchange¶
When use_replica_exchange=True (default), Hamiltonian Replica Exchange
(HREX, Liu, Pu et al. PNAS 2005)
is performed between adjacent
lambda windows after every episode, swapping their configurations according to a
Metropolis criterion. This
improves sampling by letting configurations diffuse across the whole lambda
path. The number of steps per episode (and therefore the HREX frequency)
is simulation_config.num_steps / simulation_config.num_episodes.
num_equilibration_episodes sets the number of initial episodes to run
before HREX attempts begin. Setting use_replica_exchange=False runs every
lambda window fully independently.
Checkpointing¶
Setting checkpoint_dir on
FEPSimulationSamplerConfig
saves a checkpoint (per-lambda simulation state, replica exchange PRNG key,
and completed episode count) to that local directory every
checkpoint_interval_episodes episodes.
To also upload the checkpoint to remote storage after each save, pass a
checkpoint_dir_upload_fun callable to the sampler’s constructor:
from pathlib import Path
def upload_checkpoint_dir(checkpoint_dir: Path) -> None:
"""Upload the checkpoint directory to remote storage."""
_upload_directory_to_remote_storage( # placeholder
checkpoint_dir, "s3://my-bucket/checkpoints/run_1"
)
sampler = FEPSimulationSampler(
atoms,
force_field,
sampler_config, # sampler_config.checkpoint_dir must be set
checkpoint_dir_upload_fun=upload_checkpoint_dir,
)
sampler.run()
To resume a run, call sampler.restore_checkpoint(checkpoint_dir) with a local
directory before calling sampler.run(); if the checkpoint directory is in remote
storage, download it first:
local_checkpoint_dir = Path("/path/to/local/checkpoint_dir")
_download_directory_from_remote_storage(
"s3://my-bucket/checkpoints/run_1", local_checkpoint_dir
)
sampler = FEPSimulationSampler(atoms, force_field, sampler_config)
sampler.restore_checkpoint(local_checkpoint_dir) # must be called before run()
sampler.run()
Simulation state and logging¶
As with the standard engines, loggers can be attached to a
FEPSimulationSampler
via attach_logger. Unlike the single-engine case, the logging function must
take two arguments: the
FEPSimulationState,
and an integer index identifying which lambda window it belongs to (or None
if it is the shared global state, which only carries replica_exchange_log).
Note also that due to the long simulation times required, if a logger performs expensive operations (e.g. uploading to remote storage) then you may want to limit the logging frequency:
from mlip.fep.sampler.states import FEPSimulationState
def make_logger():
episode_counts: dict[int | None, int] = {}
def logging_fun(state: FEPSimulationState, engine_index: int | None) -> None:
episode_counts[engine_index] = episode_counts.get(engine_index, 0) + 1
if episode_counts[engine_index] % log_interval_episodes != 0:
return
_log_something(state, engine_index) # placeholder, e.g. an s3 upload
return logging_fun
sampler.attach_logger(make_logger())
FEPSimulationState extends
SimulationState with:
Field |
Description |
|---|---|
|
The energy of this window’s configuration evaluated at every lambda
value in the schedule, at each logged snapshot. Shape
|
|
The log of replica exchange attempts and outcomes. Only populated on
the shared global state ( |
|
The final positions, velocities and cell of this window’s simulation. |