AIMMD multi-system (multi-ligand) tutorial

This notebook shows how one AIMMD params file can drive several chemical systems at once and train a single shared committor model that works for either system. It is the multi-system counterpart of 1_toy_1d.ipynb and, like it, runs end-to-end on the toy engine (no GROMACS / no GPU) in a couple of minutes.

We set up two toy “ligands” with different atom counts (1 and 2 atoms) to demonstrate the part that makes a shared model non-trivial: a fixed descriptor_transform / atom_types encoding maps both systems into the same network input space.

What we cover:

  1. Two toy systems and their initial transition paths.

  2. A single multi-system params.py (multi_system=True).

  3. A shared-network campaign (multi_system_share_network=True): one trainer, one network, balanced pooling of both systems.

  4. Inspecting the per-system run layout and the one shared network.

  5. The separate-networks mode for contrast.

  6. Per-system kinetics convergence with the shared model.

  7. An in-state bias (OPES-style) run with per-system Tiwary-Parrinello bias-reweighted rates.

0. Imports and working directory

Everything is created in a fresh temporary directory so the notebook is self-contained and repeatable.

import os
import json
import tempfile
import numpy as np
import MDAnalysis as mda
import matplotlib.pyplot as plt
import aimmd

work = tempfile.mkdtemp(prefix="aimmd_multi_")
os.chdir(work)
print("working directory:", work)
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
working directory: /tmp/aimmd_multi_2aieb_61

1. Two toy systems with different atom counts

Each “ligand” is a toy particle whose first coordinate sweeps 0 -> 10, which is a clean A -> R -> B transition for the state function below. System s1 has 1 atom; system s2 has 2 atoms (the extra atom is inert and ignored by the analysis functions). Writing different atom counts is the whole point: the shared network must consume both.

def write_initial(fname, n_atoms, n_frames=50):
    u = mda.Universe.empty(n_atoms, trajectory=True)
    sweep = np.linspace(0.0, 10.0, n_frames)
    with mda.Writer(fname, n_atoms) as w:
        for x in sweep:
            u.atoms.positions = np.column_stack([
                np.full(n_atoms, x), np.full(n_atoms, 5.0),
                np.full(n_atoms, 5.0)]).astype(np.float32)
            w.write(u.atoms)

write_initial("s1.xtc", n_atoms=1)
write_initial("s2.xtc", n_atoms=2)
print("s1 atoms:", mda.Universe("s1.xtc").atoms.n_atoms)
print("s2 atoms:", mda.Universe("s2.xtc").atoms.n_atoms)
s1 atoms: 1
s2 atoms: 2
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))

2. One multi-system params file

The key fields:

  • multi_system = True turns on multi-system mode.

  • multi_system_share_network = True trains one network for both systems.

  • system_ids, list-valued topology, and grouped initial_paths give one entry per system.

  • states_function(traj, system_id=...) uses the system_id keyword to apply a per-ligand state boundary.

  • descriptor_transform(coords, system_id=...) maps any atom count to a fixed width (here the first atom’s x), so one Linear(1, ...) network consumes both systems. (For real graph networks you would instead pass a fixed atom_types table; the mechanism is identical.)

  • fit simply forwards to the default trainer — its only change for multi-system is that it now accepts a list of PathEnsembles.

PARAMS = """
import numpy as np
import torch
from aimmd.network import fit as _fit
from aimmd.network.rescalable import Rescalable

engine = 'toy'
multi_system = True
multi_system_share_network = True
system_ids = ['s1', 's2']
topology = ['s1.xtc', 's2.xtc']
initial_paths = [['s1.xtc'], ['s2.xtc']]
free_overriding_states = 'all'


def toy_mdrun(ts):
    for _ in range(50):
        ts.positions = (ts.positions + .05 * np.random.normal()) % 10


def states_function(trajectory, system_id=None):
    cut_a = 2.0 if system_id == 's1' else 2.5      # per-ligand A boundary
    out = []
    for frame in trajectory:
        x = frame.positions[0, 0]
        out.append('A' if x < cut_a else ('B' if x > 8.0 else 'R'))
    return np.array(out, dtype='<U1')


def descriptor_transform(coordinates, system_id=None):
    arr = np.asarray(coordinates).reshape(np.asarray(coordinates).shape[0], -1, 3)
    return arr[:, :1, 0]                            # fixed width for any system


class Network(Rescalable):
    def __init__(self):
        super().__init__()
        self.input = torch.nn.Linear(1, 16)
        self.act = torch.nn.ReLU()
        self.output = torch.nn.Linear(16, 1)
        self.reset_parameters()
    def forward(self, x):
        return self.output(self.act(self.input(x[:, :1])))
    def reset_parameters(self):
        self.input.reset_parameters(); self.output.reset_parameters()

network = Network()


def fit(params, pathensemble, verbose=False, worker=None):
    return _fit(params, pathensemble, nbins=0, state_bins='all', augment='no',
                lr=1e-3, epochs=40, batch_size=128, stop=1e9,
                in_memory=True, graphs=False, verbose=verbose, worker=worker)
"""
with open("params.py", "w") as fh:
    fh.write(PARAMS)
print("wrote params.py")
wrote params.py

3. Load the params and inspect the multi-system plumbing

Params builds one MDAnalysis universe per system and keeps the initial paths grouped per system.

params = aimmd.Params.load("params.py")
print("multi_system          :", params.multi_system)
print("share network         :", params.multi_system_share_network)
print("system_ids            :", params.system_ids)
print("topology (per system) :", params.topology)
print("universe atom counts  :",
      {sid: params.universe_of(sid).atoms.n_atoms for sid in params.system_ids})
print("initial-path groups   :", len(params.initial_paths))
Written full params with descriptions to 'params1.py'
multi_system          : True
share network         : True
system_ids            : ['s1', 's2']
topology (per system) : ['s1.xtc', 's2.xtc']
universe atom counts  : {'s1': 1, 's2': 2}
initial-path groups   : 2

4. Run a shared-network campaign

Launcher.run(n, n1, n2) worker counts apply per system. With n=1, n1=1, n2=1 each of s1 and s2 gets one shooting + one freeA + one freeB worker, and a single shared trainer fits the network on both systems’ ensembles. (nsteps is tiny here — this is a functionality demo, not a converged run.)

launcher = aimmd.Launcher("params.py", "run1")
launcher.run(n=1, n1=1, n2=1, nsteps=10, walltime=300)
print("campaign finished")
Assigned parameters file 'params1.py'
Assigned parameters file 'params1.py'
+++ created 'run1'
+++ created 'run1/s1'
+++ created 'run1/s1/initialARB'
+++ saved 'run1/s1/initialARB/s1.xtc.trr' (from: 's1.xtc')
+++ saved 'run1/s1/initialARB/s1.xtc.trr.states.npy'
+++ created run1/s1/freeA
+++ created run1/s1/freeB
+++ created run1/s1/chainR0
+++ created 'run1/s2'
+++ created 'run1/s2/initialARB'
+++ saved 'run1/s2/initialARB/s2.xtc.trr' (from: 's2.xtc')
+++ saved 'run1/s2/initialARB/s2.xtc.trr.states.npy'
+++ created run1/s2/freeA
+++ created run1/s2/freeB
+++ created run1/s2/chainR0
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
[PID 685798, TID 140120739104576: "run1" ARB trainer] starting (Fri Jun 19 11:41:14 2026)
...   args: ('params1.py', 'run1', np.int64(6), np.int64(4), 1, 'trainARB.log', 300.0, np.float64(10.0), np.float64(inf), 59.0, 'train', np.float64(inf), True)
[PID 685798, TID 140120739104576: "run1" ARB trainer] exited correctly
[PID 685795, TID 140478459070272: "run1/s2" freeA (worker0)] starting (Fri Jun 19 11:41:14 2026)
...   args: ('params1.py', 'run1/s2', np.int64(3), np.int64(4), 1, 'freeA/worker0.log', inf, inf, inf, 59.0, 'free', 'A', 0, 1, np.True_)
[PID 685795, TID 140478459070272: "run1/s2" freeA (worker0)] exited correctly
[PID 685794, TID 140512219678528: "run1/s1" chainR0] starting (Fri Jun 19 11:41:14 2026)
...   args: ('params1.py', 'run1/s1', np.int64(2), np.int64(4), 1, 'chainR0/worker.log', inf, inf, inf, 59.0, 'shoot', 'R', 0, False)
[PID 685794, TID 140512219678528: "run1/s1" chainR0] exited correctly
[PID 685796, TID 140596595771200: "run1/s2" freeB (worker0)] starting (Fri Jun 19 11:41:14 2026)
...   args: ('params1.py', 'run1/s2', np.int64(4), np.int64(4), 1, 'freeB/worker0.log', inf, inf, inf, 59.0, 'free', 'B', 0, 1, np.True_)
[PID 685796, TID 140596595771200: "run1/s2" freeB (worker0)] exited correctly
[PID 685797, TID 140286326515520: "run1/s2" chainR0] starting (Fri Jun 19 11:41:14 2026)
...   args: ('params1.py', 'run1/s2', np.int64(5), np.int64(4), 1, 'chainR0/worker.log', inf, inf, inf, 59.0, 'shoot', 'R', 0, False)
[PID 685797, TID 140286326515520: "run1/s2" chainR0] exited correctly
[PID 685792, TID 140404037658432: "run1/s1" freeA (worker0)] starting (Fri Jun 19 11:41:14 2026)
...   args: ('params1.py', 'run1/s1', np.int64(0), np.int64(4), 1, 'freeA/worker0.log', inf, inf, inf, 59.0, 'free', 'A', 0, 1, np.True_)
[PID 685792, TID 140404037658432: "run1/s1" freeA (worker0)] exited correctly
[PID 685793, TID 139793861736256: "run1/s1" freeB (worker0)] starting (Fri Jun 19 11:41:14 2026)
...   args: ('params1.py', 'run1/s1', np.int64(1), np.int64(4), 1, 'freeB/worker0.log', inf, inf, inf, 59.0, 'free', 'B', 0, 1, np.True_)
[PID 685793, TID 139793861736256: "run1/s1" freeB (worker0)] exited correctly
campaign finished

5. The per-system layout and the one shared network

A multi-system run nests one level: each system has its own subfolder with its own chains, free simulations and adaptive bins/densities. The single shared network lives at the run root and is read by every system’s workers.

def tree(root, depth=0):
    for name in sorted(os.listdir(root)):
        path = os.path.join(root, name)
        print("  " * depth + ("[d] " if os.path.isdir(path) else "    ") + name)
        if os.path.isdir(path) and depth < 1:
            tree(path, depth + 1)

tree("run1")
print()
print("shared network at root :", os.path.isfile("run1/networkARB.h5"))
print("per-system networks    :",
      {sid: os.path.isfile(f"run1/{sid}/networkARB.h5") for sid in params.system_ids})
print("per-system bins        :",
      {sid: os.path.isfile(f"run1/{sid}/binsARB.npy") for sid in params.system_ids})
    networkARB.h5
    networkARB.step000000.h5
    networkARB.step000010.h5
[d] s1
      binsARB.npy
  [d] chainR0
      densitiesARB.npy
  [d] freeA
  [d] freeB
  [d] initialARB
[d] s2
      binsARB.npy
  [d] chainR0
      densitiesARB.npy
  [d] freeA
  [d] freeB
  [d] initialARB
    trainARB.log

shared network at root : True
per-system networks    : {'s1': False, 's2': False}
per-system bins        : {'s1': True, 's2': True}

6. The shared model predicts the committor for both systems

We load the one shared network and evaluate the committor along the reaction coordinate for each system. Because the systems have different atom counts, this only works thanks to the shared descriptor_transform encoding.

import torch
from scipy.special import expit

params.update_network("run1")          # loads run1/networkARB.h5 (shared)
xs = np.linspace(0, 10, 60)

plt.figure(figsize=(6, 4))
for sid, n_atoms in (("s1", 1), ("s2", 2)):
    coords = np.zeros((len(xs), n_atoms * 3), dtype=np.float32)
    coords[:, 0] = xs                  # atom-0 x = reaction coordinate
    logit = params.values_function(coords, system_id=sid)
    plt.plot(xs, expit(logit), "o-", ms=3, label=f"system {sid}")
plt.axhline(0.5, color="k", lw=0.7, ls="--")
plt.xlabel("reaction coordinate (atom-0 x)")
plt.ylabel("committor  $p_B$")
plt.title("One shared network, two systems")
plt.legend(); plt.tight_layout(); plt.show()
../_images/247a7cc83a7aa69a4bc761d63c77e6e59d24a48d2eb31d1d94061b39a856affb.png

7. Separate networks (for contrast)

With multi_system_share_network = False, each ligand trains its own network in its own subfolder (one trainer per system; trainers_share_gpu decides whether they share a GPU). Everything else — the single params file, the subfolder layout, the per-system worker counts — is identical.

sep = PARAMS.replace("multi_system_share_network = True",
                     "multi_system_share_network = False")
os.makedirs("sep", exist_ok=True)
write_initial("sep/s1.xtc", 1); write_initial("sep/s2.xtc", 2)
with open("sep/params.py", "w") as fh:
    fh.write(sep)
here = os.getcwd(); os.chdir("sep")
try:
    aimmd.Launcher("params.py", "run1").run(n=1, n1=1, n2=1, nsteps=10, walltime=300)
    print("per-system networks    :",
          {sid: os.path.isfile(f"run1/{sid}/networkARB.h5") for sid in ("s1", "s2")})
    print("shared network at root :", os.path.isfile("run1/networkARB.h5"))
finally:
    os.chdir(here)
Written full params with descriptions to 'params1.py'
Assigned parameters file 'params1.py'
+++ created 'run1'
+++ created 'run1/s1'
+++ created 'run1/s1/initialARB'
+++ saved 'run1/s1/initialARB/s1.xtc.trr' (from: 's1.xtc')
+++ saved 'run1/s1/initialARB/s1.xtc.trr.states.npy'
+++ created run1/s1/freeA
+++ created run1/s1/freeB
+++ created run1/s1/chainR0
+++ created 'run1/s2'
+++ created 'run1/s2/initialARB'
+++ saved 'run1/s2/initialARB/s2.xtc.trr' (from: 's2.xtc')
+++ saved 'run1/s2/initialARB/s2.xtc.trr.states.npy'
+++ created run1/s2/freeA
+++ created run1/s2/freeB
+++ created run1/s2/chainR0
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
[PID 687250, TID 140155376600896: "run1/s2" ARB trainer] starting (Fri Jun 19 11:42:09 2026)
...   args: ('params1.py', 'run1/s2', np.int64(3), np.int64(3), 1, 'trainARB.log', 300.0, np.float64(10.0), np.float64(inf), 59.0, 'train', np.float64(inf), True)
[PID 687250, TID 140155376600896: "run1/s2" ARB trainer] exited correctly
[PID 687242, TID 139653970908992: "run1/s1" freeA (worker0)] starting (Fri Jun 19 11:42:09 2026)
...   args: ('params1.py', 'run1/s1', np.int64(0), np.int64(3), 1, 'freeA/worker0.log', inf, inf, inf, 59.0, 'free', 'A', 0, 1, np.True_)
[PID 687242, TID 139653970908992: "run1/s1" freeA (worker0)] exited correctly
[PID 687246, TID 140131098457920: "run1/s2" freeA (worker0)] starting (Fri Jun 19 11:42:09 2026)
...   args: ('params1.py', 'run1/s2', np.int64(4), np.int64(3), 1, 'freeA/worker0.log', inf, inf, inf, 59.0, 'free', 'A', 0, 1, np.True_)
[PID 687246, TID 140131098457920: "run1/s2" freeA (worker0)] exited correctly
[PID 687245, TID 140556642162496: "run1/s1" ARB trainer] starting (Fri Jun 19 11:42:09 2026)
...   args: ('params1.py', 'run1/s1', np.int64(3), np.int64(3), 1, 'trainARB.log', 300.0, np.float64(10.0), np.float64(inf), 59.0, 'train', np.float64(inf), True)
[PID 687245, TID 140556642162496: "run1/s1" ARB trainer] exited correctly
[PID 687244, TID 139696671479616: "run1/s1" chainR0] starting (Fri Jun 19 11:42:09 2026)
...   args: ('params1.py', 'run1/s1', np.int64(2), np.int64(3), 1, 'chainR0/worker.log', inf, inf, inf, 59.0, 'shoot', 'R', 0, False)
[PID 687244, TID 139696671479616: "run1/s1" chainR0] exited correctly
[PID 687248, TID 139690070644544: "run1/s2" freeB (worker0)] starting (Fri Jun 19 11:42:09 2026)
...   args: ('params1.py', 'run1/s2', np.int64(5), np.int64(3), 1, 'freeB/worker0.log', inf, inf, inf, 59.0, 'free', 'B', 0, 1, np.True_)
[PID 687248, TID 139690070644544: "run1/s2" freeB (worker0)] exited correctly
[PID 687243, TID 139869902411584: "run1/s1" freeB (worker0)] starting (Fri Jun 19 11:42:09 2026)
...   args: ('params1.py', 'run1/s1', np.int64(1), np.int64(3), 1, 'freeB/worker0.log', inf, inf, inf, 59.0, 'free', 'B', 0, 1, np.True_)
[PID 687243, TID 139869902411584: "run1/s1" freeB (worker0)] exited correctly
[PID 687249, TID 139816219973440: "run1/s2" chainR0] starting (Fri Jun 19 11:42:09 2026)
...   args: ('params1.py', 'run1/s2', np.int64(6), np.int64(3), 1, 'chainR0/worker.log', inf, inf, inf, 59.0, 'shoot', 'R', 0, False)
[PID 687249, TID 139816219973440: "run1/s2" chainR0] exited correctly
per-system networks    : {'s1': True, 's2': True}
shared network at root : False

8. Per-system kinetics convergence (shared model)

kinetics_convergence works with the shared model: at each data fraction it retrains the shared network on both systems’ sub-sampled ensembles and reports rates per system. The result array carries a system field.

worker = aimmd.Worker(params, "run1", walltime=300)
results = worker.kinetics_convergence(fractions=[0.5, 1.0])
print("fields:", results.dtype.names)
for row in results:
    print(f"  system {row['system']:>3}  fraction {row['fraction']:.2f}  "
          f"k12={row['k12']:.2e}  k21={row['k21']:.2e}")
Press Control+C to interrupt.
Worker's resources info
-----------------------
LocalID 0
CPU ids: 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27
GPU ids: 0
-----------------------
Saving current network state (Fri Jun 19 11:43:09 2026)
=== Kinetics convergence: fraction 0.50 === ((Fri Jun 19 11:43:09 2026))
Training shared network on 50% of data (Fri Jun 19 11:43:09 2026)
Collected       741 in A frames (   108 paths),
                508 in B frames (    64 paths),
               1212 free A to A frames (    94 paths),
                964 free B to B frames (    41 paths),
                191 free A to B frames (     2 paths),
                224 free B to A frames (     4 paths),
                270 shot A to A frames (     2 paths),
                428 shot B to B frames (     3 paths),
                  0 shot A to B frames (     0 paths), and
                  0 shot B to A frames (     0 paths)
   TOTAL:      4538 frames
   Per-system breakdown:
     [system s1] inA=225 inB=212 fAA=747 fBB=620 fAB=140 fBA=147 sAA=0 sBB=383 sAB=0 sBA=0  | TOTAL 2474 frames (144 paths)
     [system s2] inA=516 inB=296 fAA=465 fBB=344 fAB=51 fBA=77 sAA=270 sBB=45 sAB=0 sBA=0  | TOTAL 2064 frames (174 paths)

Calculating shooting results and sel. probabilities (Fri Jun 19 11:43:10 2026)

Bins [-inf  inf]

Training set size 1254
Resetting the network parameters (Fri Jun 19 11:43:10 2026)

Starting the training cycle (Fri Jun 19 11:43:10 2026)

Training took 0.4s
    42 epochs
    last loss 4.201e-01
    last scale 3.135
    last range (-0.020, 3.135)
    [system 's1'] k12=9.736e-04 k21=1.376e-03 [1/dt], 2693 frames
    [system 's2'] k12=5.410e-04 k21=8.533e-04 [1/dt], 2564 frames
=== Kinetics convergence: fraction 1.00 === ((Fri Jun 19 11:43:10 2026))
Training shared network on 100% of data (Fri Jun 19 11:43:10 2026)
Collected       906 in A frames (   127 paths),
                787 in B frames (   101 paths),
               1034 free A to A frames (   106 paths),
               1309 free B to B frames (    72 paths),
                872 free A to B frames (     8 paths),
                224 free B to A frames (     4 paths),
                331 shot A to A frames (     3 paths),
                457 shot B to B frames (     4 paths),
                254 shot A to B frames (     1 paths), and
                523 shot B to A frames (     2 paths)
   TOTAL:      6697 frames
   Per-system breakdown:
     [system s1] inA=295 inB=417 fAA=564 fBB=819 fAB=575 fBA=147 sAA=0 sBB=383 sAB=254 sBA=313  | TOTAL 3767 frames (208 paths)
     [system s2] inA=611 inB=370 fAA=470 fBB=490 fAB=297 fBA=77 sAA=331 sBB=74 sAB=0 sBA=210  | TOTAL 2930 frames (220 paths)

Calculating shooting results and sel. probabilities (Fri Jun 19 11:43:11 2026)

Bins [-inf  inf]

Training set size 1703
Resetting the network parameters (Fri Jun 19 11:43:11 2026)

Starting the training cycle (Fri Jun 19 11:43:11 2026)
!!! bad range (-0.922, 0.099), restoring original parameters

Training took 0.6s
    40 epochs
    last loss 8.845e-01
    last scale 3.136
    last range (-0.021, 3.136)
    [system 's1'] k12=3.270e-03 k21=1.642e-03 [1/dt], 5595 frames
    [system 's2'] k12=2.061e-03 k21=1.096e-03 [1/dt], 5147 frames
Restoring original network state (Fri Jun 19 11:43:12 2026)
Saved per-system kinetics convergence to 'kinetics_convergence.npy'
fields: ('fraction', 'system', 'n_frames', 'k12', 'k21', 'k12_rw', 'k21_rw')
  system  s1  fraction 0.50  k12=9.74e-04  k21=1.38e-03
  system  s2  fraction 0.50  k12=5.41e-04  k21=8.53e-04
  system  s1  fraction 1.00  k12=3.27e-03  k21=1.64e-03
  system  s2  fraction 1.00  k12=2.06e-03  k21=1.10e-03

9. In-state bias (OPES-style) with the shared model

Real campaigns often apply an in-state bias (e.g. a frozen OPES_METAD that flattens each ligand’s bound well) to speed up transitions, then recover unbiased kinetics with the Tiwary-Parrinello correction k = 1 / Σ(wᵢ·Lᵢ·γᵢ), γᵢ = ⟨exp(bias)⟩. This works in multi-system mode too:

  • record_bias = True and a bias_function returning the per-frame bias in kT (here a toy reader-mode bias that is non-zero only inside state A, so it is negligible in the reactive region R — the Tiwary-Parrinello assumption). The system_id keyword is forwarded, so the bias can differ per ligand.

  • bias_reactive_threshold may be a per-system list.

The trainer caches the per-frame bias per system and reports per-system bias-reweighted rates; kinetics_convergence fills the k12_rw / k21_rw columns. (In a real PLUMED run the bias instead enters GROMACS through the per-system gmx_mdrun = ['gmx mdrun -plumed G2/plumed.dat', ...] string, with bias_source='file' reading each trajectory’s COLVAR.)

import glob

bias_src = PARAMS + """
record_bias = True
bias_source = 'reader'
bias_reactive_threshold = [0.5, 0.3]          # per-system thresholds (s1, s2)


def bias_function(trajectory, system_id=None):
    cut_a = 2.0 if system_id == 's1' else 2.5
    out = []
    for frame in trajectory:
        x = frame.positions[0, 0]
        out.append(0.7 if x < cut_a else 0.0)   # bias (kT), inside state A only
    return np.array(out, dtype=float)
"""
os.makedirs("biased", exist_ok=True)
write_initial("biased/s1.xtc", 1)
write_initial("biased/s2.xtc", 2)
with open("biased/params.py", "w") as fh:
    fh.write(bias_src)

here = os.getcwd()
os.chdir("biased")
try:
    bparams = aimmd.Params.load("params.py")
    print("record_bias        :", bparams.record_bias)
    print("threshold s1 / s2  :", bparams.bias_reactive_threshold_of("s1"),
          "/", bparams.bias_reactive_threshold_of("s2"))
    aimmd.Launcher("params.py", "run1").run(n=1, n1=1, n2=1, nsteps=10, walltime=300)
    print("per-frame bias caches written:",
          len(glob.glob("run1/**/*.bias.npy", recursive=True)))
    bworker = aimmd.Worker(bparams, "run1", walltime=300)
    bres = bworker.kinetics_convergence(fractions=[1.0])
    print("\nraw vs Tiwary-Parrinello bias-reweighted rates:")
    for row in bres:
        print(f"  system {row['system']:>3}  k12={row['k12']:.2e} "
              f"(rw {row['k12_rw']:.2e})   k21={row['k21']:.2e} "
              f"(rw {row['k21_rw']:.2e})")
finally:
    os.chdir(here)
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes in this universe to guess types from
  warnings.warn(str(e))
/home/lichtinger/anaconda3/envs/new_aimmd/lib/python3.13/site-packages/MDAnalysis/core/universe.py:1916: UserWarning: there is no reference attributes (elements, types, or names) in this universe to guess mass from
  warnings.warn(str(e))
Written full params with descriptions to 'params1.py'
record_bias        : True
threshold s1 / s2  : 0.5 / 0.3
Assigned parameters file 'params1.py'
Assigned parameters file 'params1.py'
+++ created 'run1'
+++ created 'run1/s1'
+++ created 'run1/s1/initialARB'
+++ saved 'run1/s1/initialARB/s1.xtc.trr' (from: 's1.xtc')
+++ saved 'run1/s1/initialARB/s1.xtc.trr.states.npy'
+++ created run1/s1/freeA
+++ created run1/s1/freeB
+++ created run1/s1/chainR0
+++ created 'run1/s2'
+++ created 'run1/s2/initialARB'
+++ saved 'run1/s2/initialARB/s2.xtc.trr' (from: 's2.xtc')
+++ saved 'run1/s2/initialARB/s2.xtc.trr.states.npy'
+++ created run1/s2/freeA
+++ created run1/s2/freeB
+++ created run1/s2/chainR0
[PID 689127, TID 139745033889600: "run1" ARB trainer] starting (Fri Jun 19 11:43:21 2026)
...   args: ('params1.py', 'run1', np.int64(6), np.int64(4), 1, 'trainARB.log', 300.0, np.float64(10.0), np.float64(inf), 59.0, 'train', np.float64(inf), True)
[PID 689127, TID 139745033889600: "run1" ARB trainer] exited correctly
[PID 689126, TID 140521028335424: "run1/s2" chainR0] starting (Fri Jun 19 11:43:21 2026)
...   args: ('params1.py', 'run1/s2', np.int64(5), np.int64(4), 1, 'chainR0/worker.log', inf, inf, inf, 59.0, 'shoot', 'R', 0, False)
[PID 689126, TID 140521028335424: "run1/s2" chainR0] exited correctly
[PID 689122, TID 139922508396352: "run1/s1" chainR0] starting (Fri Jun 19 11:43:21 2026)
...   args: ('params1.py', 'run1/s1', np.int64(2), np.int64(4), 1, 'chainR0/worker.log', inf, inf, inf, 59.0, 'shoot', 'R', 0, False)
[PID 689122, TID 139922508396352: "run1/s1" chainR0] exited correctly
[PID 689124, TID 139858549946176: "run1/s2" freeB (worker0)] starting (Fri Jun 19 11:43:21 2026)
...   args: ('params1.py', 'run1/s2', np.int64(4), np.int64(4), 1, 'freeB/worker0.log', inf, inf, inf, 59.0, 'free', 'B', 0, 1, np.True_)
[PID 689124, TID 139858549946176: "run1/s2" freeB (worker0)] exited correctly
[PID 689121, TID 140406912313152: "run1/s1" freeB (worker0)] starting (Fri Jun 19 11:43:21 2026)
...   args: ('params1.py', 'run1/s1', np.int64(1), np.int64(4), 1, 'freeB/worker0.log', inf, inf, inf, 59.0, 'free', 'B', 0, 1, np.True_)
[PID 689121, TID 140406912313152: "run1/s1" freeB (worker0)] exited correctly
[PID 689123, TID 139733093734208: "run1/s2" freeA (worker0)] starting (Fri Jun 19 11:43:21 2026)
...   args: ('params1.py', 'run1/s2', np.int64(3), np.int64(4), 1, 'freeA/worker0.log', inf, inf, inf, 59.0, 'free', 'A', 0, 1, np.True_)
[PID 689123, TID 139733093734208: "run1/s2" freeA (worker0)] exited correctly
[PID 689120, TID 140323272247104: "run1/s1" freeA (worker0)] starting (Fri Jun 19 11:43:21 2026)
...   args: ('params1.py', 'run1/s1', np.int64(0), np.int64(4), 1, 'freeA/worker0.log', inf, inf, inf, 59.0, 'free', 'A', 0, 1, np.True_)
[PID 689120, TID 140323272247104: "run1/s1" freeA (worker0)] exited correctly
per-frame bias caches written: 86
Press Control+C to interrupt.
Worker's resources info
-----------------------
LocalID 0
CPU ids: 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27
GPU ids: 0
-----------------------
Saving current network state (Fri Jun 19 11:44:04 2026)
=== Kinetics convergence: fraction 1.00 === ((Fri Jun 19 11:44:04 2026))
Training shared network on 100% of data (Fri Jun 19 11:44:05 2026)
Collected       568 in A frames (    67 paths),
                717 in B frames (   106 paths),
                702 free A to A frames (    44 paths),
               1031 free B to B frames (    82 paths),
               1087 free A to B frames (     8 paths),
                571 free B to A frames (     5 paths),
                337 shot A to A frames (    10 paths),
                205 shot B to B frames (     1 paths),
                  0 shot A to B frames (     0 paths), and
                  0 shot B to A frames (     0 paths)
   TOTAL:      5218 frames
   Per-system breakdown:
     [system s1] inA=194 inB=363 fAA=466 fBB=586 fAB=641 fBA=258 sAA=197 sBB=0 sAB=0 sBA=0  | TOTAL 2705 frames (165 paths)
     [system s2] inA=374 inB=354 fAA=236 fBB=445 fAB=446 fBA=313 sAA=140 sBB=205 sAB=0 sBA=0  | TOTAL 2513 frames (158 paths)

Calculating shooting results and sel. probabilities (Fri Jun 19 11:44:05 2026)

Bins [-inf  inf]

Training set size 1296
Resetting the network parameters (Fri Jun 19 11:44:05 2026)

Starting the training cycle (Fri Jun 19 11:44:05 2026)
    restoring lowest loss' (4.926e-01) weights, step 49

Training took 0.5s
    60 epochs
    last loss 5.321e-01
    last scale 2.159
    last range (-0.070, 2.159)
    [system 's1'] k12=2.348e-03 k21=1.681e-03 [1/dt], 4364 frames
    Bias check passed: mean |bias| in 'R' = 0.000 kT ≤ 0.500 kT (max 0.000 kT)
    [system 's2'] k12=3.500e-03 k21=2.009e-03 [1/dt], 4461 frames
    Bias check passed: mean |bias| in 'R' = 0.000 kT ≤ 0.300 kT (max 0.000 kT)

Restoring original network state (Fri Jun 19 11:44:07 2026)
Saved per-system kinetics convergence to 'kinetics_convergence.npy'
raw vs Tiwary-Parrinello bias-reweighted rates:
  system  s1  k12=2.35e-03 (rw 2.04e-03)   k21=1.68e-03 (rw 1.67e-03)
  system  s2  k12=3.50e-03 (rw 2.62e-03)   k21=2.01e-03 (rw 1.99e-03)

Summary

With a single params file and the multi_system switches we trained one committor network that serves two systems with different atom counts, balanced so neither dominates — contrasted it with the separate-network mode, and ran an in-state-biased variant whose per-system Tiwary-Parrinello reweighting recovers unbiased rates. The same configuration scales to real systems (e.g. a graph/PaiNN network with a fixed atom_types table over many ligands), to OPES biases via the per-system gmx_mdrun -plumed string, and to live HPC campaigns via Launcher.create_job.