Weighted Ensemble (free energies & rates)
July 30, 2026 · View on GitHub
Weighted Ensemble (WE) keeps a population of trajectories ("walkers"), each
carrying a statistical weight, propagates them with unbiased dynamics, and
periodically resamples (splits over-weight walkers, merges under-weight ones)
so walkers stay spread across bins of a progress coordinate. No bias force is ever
applied — the weights keep the ensemble unbiased — so WE reuses PathGennie's exact
Engine and ParallelExecutor (multi-GPU for free).
PathGennie's stage is path-informed: it seeds walkers from a discovered
PathEnsemble and bins along that path's CV range, so WE does not have to first
find the transition. With recycling it yields a steady-state flux (rate constant);
the time-averaged bin weights give a free-energy profile along the CV.
Core pieces (pathgennie/sampling/weighted_ensemble.py)
Walker(handle, weight, bin, cv)— a weighted trajectory.GridBinner— uniform 1-D bins;GridBinner.from_values(values, n_bins)builds edges spanning the path CV range.resample(walkers, target_count, rng, clone_fn, release_fn)— Huber–Kim split/merge within one bin, conserving total weight exactly.WeightedEnsembleStage— theSamplingStageimplementation.
Using it from Python
from pathgennie.core.driver import PathGennieDriver
from pathgennie.core.toy import ToyLangevinEngine
from pathgennie.core.progress import EscapeMetric
from pathgennie.core.parallel import SerialExecutor
from pathgennie.sampling import WeightedEnsembleStage, build_path_ensemble
import numpy as np
engine = ToyLangevinEngine(dt=0.005, kT=2.0)
initial = engine.create_state((-1.0, -1.4))
# 1) discover a path, retaining restartable seeds
progress = EscapeMetric(lambda c: np.array([c[0, 1]]), start_cv=np.array([-1.4]), escape_metric="cv0")
driver = PathGennieDriver(engine, progress, lambda c: False,
executor=SerialExecutor(), sigma=0.3, seed=0, verbosity=0)
traj, metrics, handles = driver.run(initial, tau1=5, tau2=10, max_trial=6,
max_cycle=60, save_freq=1, collect_seeds=True)
ens = build_path_ensemble(traj, metrics, handles=handles, cv_fn=lambda c: c[0, 1])
# 2) run WE along the y coordinate
stage = WeightedEnsembleStage(cv_fn=lambda c: c[0, 1], tau_steps=10,
n_iterations=400, n_bins=16, target_count=6, seed=1,
kT=2.0, burn_in=0.3)
result = stage.run(ens, engine)
result.free_energy # F over result.metadata["bin_centers"]
result.weights # final walker weights (sum to 1)
result.metadata["weight_trace"] # total weight per iteration (== 1)
result.metadata["bin_weight_trace"] # (n_iterations, n_bins) occupancy
Discard the transient — burn_in
WE starts from whatever seeded it. A common and sensible choice is one walker per bin, but that is a uniform distribution: maximally unlike the Boltzmann distribution being estimated. Averaging bin occupancy from iteration 0 therefore mixes the relaxation away from that artificial start into the answer, flattening the profile and biasing barriers low.
burn_in discards leading iterations — an int counts them, a float in
(0, 1) is a fraction of the run. It defaults to 0 for backwards
compatibility, but 0 is rarely the right choice.
Do not guess the value: metadata["bin_weight_trace"] records per-iteration,
per-bin occupancy, so you can re-estimate over later and later windows and take
the point beyond which the profile stops moving — without re-running anything.
trace, kT = result.metadata["bin_weight_trace"], 2.0
def profile(window):
p = window.sum(axis=0); p = p / p.sum()
with np.errstate(divide="ignore"):
f = -kT * np.log(p)
return f - f[np.isfinite(f)].min()
second_half = profile(trace[len(trace) // 2:])
final_quarter = profile(trace[3 * len(trace) // 4:])
# agreement between these is evidence of convergence; disagreement means
# the run is too short, whatever the nominal barrier says
metadata["flux_trace"] is the per-iteration flux, and rate constants use the
same burn-in — a rate is a steady-state quantity, so the transient contaminates it
in exactly the same way.
examples/qmmm_reactive_sn2/amber/3_free_energy.py implements this check and
refuses to endorse a barrier when the windows disagree.
Rate constants (recycling)
Set recycle=True with a source_cv and target_cv; walkers crossing the
target add their weight to the flux and are re-injected at the source:
stage = WeightedEnsembleStage(
cv_fn=lambda c: c[0, 1], tau_steps=6, n_iterations=400, n_bins=16,
recycle=True, source_cv=-1.4, target_cv=1.2, timestep_ps=0.002, kT=2.0,
)
result = stage.run(ens, engine)
result.rate_constants # {"flux_per_iter": ..., "rate": ...}
rate uses tau_steps * timestep_ps when timestep_ps is given, else
per-iteration units.
Multi-GPU
Pass a ThreadDevicePool as executor= to spread the per-iteration walker
segments across GPUs — exactly as for discovery.
From input.yaml (backends)
When pathgennie.downstream: weighted_ensemble is set, the backend run() builds
the PathEnsemble (via collect_seeds) and runs WE automatically, writing
free_energy.csv (and rate_constants.json if recycling) into the output
directory:
pathgennie:
downstream: weighted_ensemble
# ... discovery settings ...
weighted_ensemble:
cv_component: 0 # which projection component to bin along
tau_steps: 1000
n_iterations: 200
n_bins: 20
target_count: 5
recycle: false
The real-MD backend path is wired through the same
run_downstreamhelper that the toy tests cover, but cannot be executed in a CPU-only sandbox. See roadmap.md.
Validation
benchmarks/we_fes.py compares the WE profile F(y) to the analytic Wolfe–Quapp
marginal:
python benchmarks/we_fes.py