diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a0bc793 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main, devel] + pull_request: + +jobs: + test: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + # Lightweight stack covering the MSM and CV-method tests. The heavy MD + # backends (openmm, MDAnalysis) are not required: tests that need them + # skip automatically via pytest.importorskip. + pip install numpy scipy scikit-learn pydantic pyyaml deeptime pytest ruff + pip install torch --index-url https://download.pytorch.org/whl/cpu + + - name: Lint (ruff) + run: ruff check autosampler/msm autosampler/execution autosampler/analysis autosampler/analysis_cli.py autosampler/init_cli.py autosampler/templates.py autosampler/binning/we.py autosampler/binning/adaptive.py autosampler/spaces/registry.py autosampler/spaces/spib.py autosampler/spaces/feature_selection.py autosampler/spaces/retraining.py autosampler/utils/seeds.py autosampler/spawners/we.py autosampler/spawners/msm.py autosampler/reporting.py autosampler/engines/base.py tests + + - name: Run test suite + run: pytest -q diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..bbd2aab --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +# Pre-commit hooks for AutoSampler. Install with: +# pip install pre-commit && pre-commit install +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-added-large-files + args: [--maxkb=1024] diff --git a/AutoSampler_Changelog.pdf b/AutoSampler_Changelog.pdf new file mode 100644 index 0000000..f40d10f Binary files /dev/null and b/AutoSampler_Changelog.pdf differ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fd56b2f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,139 @@ +# Changelog + +All notable changes to AutoSampler are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/), and the project aims to follow +[Semantic Versioning](https://semver.org/). + +## [Unreleased] — `devel` + +This cycle turns AutoSampler from a coverage-driven adaptive sampler into an +**MSM-convergence-driven** framework, hardens the engineering foundation, and +adds first-class **HPC scalability** and **VAMP-2 feature optimisation**. It also +adds a flux-weighted **transition-matrix convergence** gate with +**uncertainty-guided spawning**, and opt-in **landscape-adaptive binning**. + +### Added + +#### MSM convergence engine (Phase 1) +- New `autosampler/msm/` subsystem built on `deeptime`: + - `MSMEstimator` — clustering (k-means / regular-space) on the CV/latent + space → transition counts → MLE **or** Bayesian MSM → implied timescales, + VAMP-2 score, PCCA+ metastable states, stationary distribution. + - `diagnostics.py` — serialisable implied-timescale / Chapman-Kolmogorov / + VAMP results. + - `ConvergenceMonitor` with composable, pluggable criteria: implied-timescale + stability, VAMP-2 plateau, stationary-distribution drift, Bayesian + statistical-error thresholds, and a **flux-weighted transition-matrix** + criterion (analytic Dirichlet `T_ij` uncertainty) — combined with + `all` / `any` + patience. +- `MSMSpawner` (`spawn_scheme: msm`) — **uncertainty × leverage × flux** + microstate seeding (`π_i · |ψ_i| · σ_out,i + α/√c_i`) on the estimator's shared + clustering, throwing runs at the transitions whose in/out rates are uncertain + and important; least-counts fallback before the first MSM. Knobs + `msm.spawn_alpha` / `spawn_leverage` / `spawn_uncertainty`; `msm.stable_clustering` + keeps microstate IDs comparable across iterations. +- **Weighted-ensemble resampling** — a real `WeightedEnsemble` split/merge core + (Huber & Kim, weight-conserving) and `WESpawner` (`spawn_scheme: we`, + `we_target_per_bin`), replacing the former placeholder. +- `MSMConfig` — all MSM behaviour is **opt-in** (`msm.enabled: false` by + default), so existing configs are unaffected. +- Per-iteration MSM results are written to the run directory and checkpointed + for resume. + +#### MSM analysis & plotting +- `autosampler/analysis/` — matplotlib-free data utilities (`load_msm_series`, + `load_latest_msm`, `load_cv_points`, free energies, free-energy surface) plus + `plots` (implied timescales, VAMP-2 / timescale convergence, free-energy + surface, metastable free energies, MSM network) and a one-command + `autosampler-analyze` CLI producing a multi-panel convergence report. +- `msm.npz` now also stores the implied-timescale sweep and metastable + populations for plotting. + +#### Extensible ML collective variables (Phase 1) +- `autosampler/spaces/registry.py` — single source of truth for CV methods, + their backends, and availability. Adds **VAMPNet** and **SPIB** (State + Predictive Information Bottleneck) alongside TICA, TVAE, PCA, deep-TICA, and + deep-LDA, with actionable errors when an optional backend is missing. + +#### HPC execution backends (Phase 3) +- `autosampler/execution/` — pluggable `ExecutionBackend` (factory pattern): + - `local` — multiprocessing across CPU/GPU slots on one node (multi-GPU + workstation); preserves the original GPU-slot scheduling. + - `slurm` / `pbs` — one **array job per iteration**, with completion driven by + filesystem result markers and automatic **resubmission** of failed walkers + (`execution.max_retries`). +- `ExecutionConfig` — backend selection plus scheduler resources (partition / + queue, account, walltime, cpus/gpus per task, memory, module loads, extra + directives). Defaults to `local`. + +#### VAMP-2 input-feature selection +- `autosampler/spaces/feature_selection.py` — `vamp2_score`, `rank_candidates`, + `greedy_vamp_selection`, and `FeatureSelector`: choose and **adaptively + update** the input features that best resolve the slow dynamics. +- `FeatureSelectionConfig` (`feature_selection.enabled`, opt-in) — re-selects + feature columns every `cadence` iterations; selection persisted for resume. + +#### Landscape-adaptive binning +- `autosampler/binning/adaptive.py` — `AdaptiveBinner` + `BinnerFactory` with + `gradient` (equi-resistance: fine bins where the density is low / barriers), + `mab` (Minimal-Adaptive-Binning style front footholds), and `eigenvector` (bin + along the leading slow CV coordinate) schemes alongside `uniform`. Selected via + `binning.scheme`; wired into the density and weighted-ensemble spawners; opt-in, + default `uniform` reproduces the constant-width grid exactly. + +#### Adaptive CV quality & reproducibility (Phase 4) +- **VAMP-2-driven adaptive retraining** (`retrain_policy: vamp_adaptive`): + `RetrainController` retrains the CV only when its VAMP-2 score on fresh data + drops by more than `vamp_retrain_tol` below its reference (with + `retrain_min_interval` / `retrain_max_interval` bounds). Reference score is + checkpointed. The default `fixed` policy preserves the legacy schedule. +- **Reproducibility:** `SeedManager` now also seeds PyTorch Lightning + (`seed_everything`, used by deep-TICA/LDA) and documents determinism limits. +- **Feature-type selection:** `feature_selection.candidate_feature_types` ranks + whole feature types (`distances` / `fitted_coords` / `phi_psi`) by VAMP-2 and + switches the loop to the best one (re-running column selection on a change). + +#### End-user input file & tutorial +- **`autosampler-init`** writes a fully-annotated starter input file + (`autosampler/templates.py`, mirrored to `examples/template.yaml`) covering + every section, method choice, and hyperparameter. Documented in + `docs/input_file.md`. +- **Jupyter notebook tutorial** with rendered plots + (`examples/notebooks/adaptive_msm_tutorial.ipynb`): input file, VAMP-2 feature + selection, MSM estimation, convergence, weighted ensemble, and the analysis + report — all on fast synthetic data. + +#### Tooling & docs +- Test suite (`pytest`) covering MSM, CV methods, execution backends, feature + selection, config, spawners, and hardening — **49 tests**. +- GitHub Actions CI (Python 3.10 / 3.11) running ruff + pytest. +- `ruff` / `black` / `isort` config, `.pre-commit-config.yaml`, `CONTRIBUTING.md`. +- `docs/` site (MkDocs) and tutorials; `CHANGELOG.md`; example run scripts for + local, SLURM, and PBS. + +### Changed (Phase 2 — engineering foundation) +- Refactored the per-iteration UI out of `core.py` into + `autosampler/reporting.py` (`IterationReporter`); de-duplicated project-file + CV loading. +- Migrated configuration to **Pydantic v2** (`field_validator` / + `model_validator`, `model_dump()`); pinned `pydantic>=2.0`. +- Added **checkpoint format versioning** and generalised torch-encoder + snapshots to tvae / vampnet / spib. +- Made `autosampler.spaces` import lazily so lightweight modules (e.g. the CV + registry) import without MDAnalysis / torch. + +### Fixed (Phase 2) +- Renamed `AdaptiveSpaceModel.fited` → `fitted` (with a backwards-compatible + loader for old checkpoints). +- Added MD subprocess timeouts (`AUTOSAMPLER_MD_TIMEOUT`) for GROMACS / Amber. +- Validate trajectory files exist and are non-empty before CV extraction. +- Replaced hardcoded `/tmp` with `tempfile.gettempdir()`; narrowed an + over-broad exception handler. +- Removed the dead `WEResampler` stub. + +## [2.0.0] — baseline + +Modular adaptive sampling framework: OpenMM / GROMACS / Amber engines, fixed or +learned (TICA / TVAE / PCA / deep-TICA) CV spaces, density / Voronoi / LOF / FPS +spawners, bin-occupancy convergence, checkpoint/restart, and lineage-aware path +reconstruction. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..35de72c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,58 @@ +# Contributing to AutoSampler + +Thanks for your interest in improving AutoSampler. This guide covers the +development workflow, coding standards, and how to add new components. + +## Development setup + +```bash +conda env create -f env.yml +conda activate autosampler +pip install -e ".[all]" # runtime + deep-tica + examples + test extras +pip install pre-commit && pre-commit install +``` + +For a lightweight setup that runs the MSM and CV-method tests without the heavy +MD backends (OpenMM, MDAnalysis): + +```bash +pip install numpy scipy scikit-learn pydantic pyyaml deeptime pytest torch +``` + +## Running tests and linters + +```bash +pytest -q # full test suite (MD-dependent tests self-skip) +ruff check . # lint +ruff format . # format (or `black .`) +``` + +CI runs the test suite on Python 3.10 and 3.11 (see `.github/workflows/ci.yml`). +Please add or update tests for any behaviour change; aim to keep new modules +covered. + +## Coding standards + +- Target Python 3.10+. Use type hints on public functions. +- Formatting and linting are enforced by ruff/black (line length 88). +- Keep presentation/IO out of core logic (see `autosampler/reporting.py`). +- Prefer the existing factory/registry patterns when adding components. + +## Adding components + +AutoSampler is built around small registries so new methods slot in cleanly: + +- **MD engine** — subclass `MDEngine` and call `EngineFactory.register(...)` + in `autosampler/engines/`. +- **Spawner** — subclass `Spawner` and call `SpawnerFactory.register(...)` + in `autosampler/spawners/`. +- **CV method** — add a `CVMethod` to `autosampler/spaces/registry.py` and a + branch in `AdaptiveSpaceModel.fit` / `.project`. +- **MSM convergence criterion** — subclass `ConvergenceCriterion` and register + it in `autosampler/msm/convergence.py`. + +## Commit / PR guidelines + +- Write focused commits with descriptive messages. +- Ensure `pytest` and `ruff check` pass locally before opening a PR. +- Describe the scientific or engineering motivation in the PR description. diff --git a/README.md b/README.md index 91a911e..fa0b6e1 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,39 @@ AutoSampler is a Python framework for adaptive molecular dynamics campaigns. It runs many short MD walkers, projects saved frames into a collective-variable or learned latent space, chooses informative restart frames, and repeats the -cycle with checkpointed provenance. +cycle — continuing until a **Markov State Model (MSM)** built on the sampled +data has **converged**. The code is meant for method development and practical sampling workflows where you need to change engines, CVs, spawning policies, or analysis criteria without rewriting the whole pipeline. +## What's new + +- **MSM-convergence engine** — build an MSM each iteration (deeptime) and stop + automatically on implied-timescale / VAMP-2 convergence *and* a flux-weighted + **transition-matrix** statistical-error gate (`spawn_scheme: msm`, `msm.enabled`). +- **Uncertainty-guided spawning** — seed walkers by **uncertainty × leverage × + flux** to drive the MSM toward convergence fastest (`msm.spawn_uncertainty`). +- **Landscape-adaptive binning** — place bins finer across barriers and coarser + in basins (`binning.scheme: gradient | mab | eigenvector`), recomputed each + iteration; defaults to the uniform grid. +- **More learned CVs** — VAMPNet and SPIB alongside TICA / TVAE / PCA / deep-TICA. +- **VAMP-2 feature selection** — optionally select and adaptively update the + input features that best resolve the slow dynamics (`feature_selection.enabled`). +- **HPC scalability** — run on a multi-GPU workstation or dispatch walkers as + **SLURM** / **PBS** array jobs (`execution.backend`). + +Get started in one command — `autosampler-init` writes an annotated input file +covering every method, feature, and hyperparameter (see +[`docs/input_file.md`](docs/input_file.md)). A runnable +[notebook tutorial](examples/notebooks/adaptive_msm_tutorial.ipynb) with rendered +plots walks through the whole workflow. + +See **[`docs/`](docs/index.md)** (full documentation & tutorials) and +**[`CHANGELOG.md`](CHANGELOG.md)**. Build the docs site with +`pip install mkdocs-material && mkdocs serve`. + ## Motivation Long molecular transitions are often missed by a single continuous trajectory. @@ -205,10 +232,44 @@ AutoSampler currently supports: - `voronoi`: KMeans-backed Voronoi cells with exact clipped polygon areas. - `lof`: local-outlier-factor based frame selection. - `fps`: farthest-point sampling for geometric spread. +- `msm`: MSM least-counts — restart from sparsely-sampled microstates to + directly reduce the statistical error of the Markov State Model. Voronoi note: `voronoi_clusters` controls the restart-selection partition. The regular `n_bins` grid is still used for run-log coverage diagnostics. +## Collective-Variable (CV) Methods + +The sampling space is chosen with `space_mode`. Beyond fixed user CVs, several +learned CV methods are available through a single registry +(`autosampler/spaces/registry.py`), so new methods can be added in one place: + +| `space_mode` | Method | Backend | Notes | +|--------------|--------|---------|-------| +| `fixed` | User CVs via a project file | — | e.g. AlaD `phi/psi` | +| `pca` | Principal component analysis | scikit-learn | linear baseline | +| `tica` | Time-lagged ICA | deeptime | linear, dynamics-aware | +| `tvae` | Time-lagged VAE | deeptime + torch | nonlinear bottleneck | +| `vampnet` | VAMPNet | deeptime + torch | trained with the VAMP-2 score | +| `spib` | State Predictive Information Bottleneck | built-in (torch) | Wang & Tiwary 2021 | +| `deep-tica` | Deep (nonlinear) TICA | mlcolvar (optional) | `pip install "autosampler[deep-tica]"` | +| `deep-lda` | Deep LDA (supervised) | mlcolvar (optional) | needs state labels | + +`vampnet` and `spib` work out of the box (only deeptime/torch). Optional methods +raise a clear, actionable error if their backend is missing. + +## MSM-Based Convergence + +With `msm.enabled: true`, AutoSampler builds a Markov State Model over the CV +space each iteration (clustering → transition counts → MLE/Bayesian MSM → +implied timescales, VAMP-2 score, PCCA+ metastable states) and stops sampling +once the MSM has **converged**. Convergence is decided by a composable +`ConvergenceMonitor` with pluggable criteria — implied-timescale stability, +VAMP-2 plateau, stationary-distribution drift, and Bayesian statistical error — +combined with `all`/`any` and a patience window. Per-iteration results are +written to `iter_*/msm.npz`. See `examples/AIB9/config_msm_vampnet.yaml` for a +complete MSM-driven adaptive-sampling configuration. + ## Typical Workflow 1. **Define the scientific question.** diff --git a/autosampler/analysis/__init__.py b/autosampler/analysis/__init__.py new file mode 100644 index 0000000..6516227 --- /dev/null +++ b/autosampler/analysis/__init__.py @@ -0,0 +1,9 @@ +"""Post-hoc MSM analysis and plotting utilities. + +``data`` holds matplotlib-free numerics (loading msm.npz/cvs.npz, free +energies); ``plots`` holds the matplotlib visualisations. +""" + +from . import data + +__all__ = ["data"] diff --git a/autosampler/analysis/data.py b/autosampler/analysis/data.py new file mode 100644 index 0000000..95228bb --- /dev/null +++ b/autosampler/analysis/data.py @@ -0,0 +1,130 @@ +"""Data utilities for post-hoc MSM analysis. + +Pure NumPy helpers (no matplotlib) that load the per-iteration ``msm.npz`` / +``cvs.npz`` files written during a run and derive quantities for plotting: +convergence series, free energies, and free-energy surfaces. Kept separate from +:mod:`autosampler.analysis.plots` so the numerics are testable without a +plotting backend. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +# Boltzmann constant in kJ/mol/K (free energies reported in kJ/mol). +KB_KJ_MOL = 0.00831446 + + +def kT(temperature: float = 300.0) -> float: + return KB_KJ_MOL * temperature + + +def _iter_dirs(run_dir: str | Path) -> list[Path]: + run_dir = Path(run_dir) + dirs = [ + p + for p in run_dir.glob("iter_*") + if p.is_dir() and p.name.removeprefix("iter_").isdigit() + ] + return sorted(dirs, key=lambda p: int(p.name.removeprefix("iter_"))) + + +def load_msm_series(run_dir: str | Path) -> dict[str, np.ndarray]: + """Collect per-iteration MSM scalars into aligned arrays. + + Returns a dict with ``iterations``, ``vamp2`` and ``timescales`` + (shape ``(n_iters, max_processes)``, NaN-padded). Iterations without an + ``msm.npz`` are skipped. + """ + iters: list[int] = [] + vamp2: list[float] = [] + timescales: list[np.ndarray] = [] + for d in _iter_dirs(run_dir): + f = d / "msm.npz" + if not f.exists(): + continue + with np.load(f, allow_pickle=True) as data: + iters.append(int(d.name.removeprefix("iter_"))) + v = data["vamp2_score"] + vamp2.append(float(np.asarray(v).ravel()[0])) + timescales.append(np.asarray(data["timescales"], dtype=float).ravel()) + + if not iters: + return { + "iterations": np.array([], dtype=int), + "vamp2": np.array([], dtype=float), + "timescales": np.zeros((0, 0), dtype=float), + } + + width = max(len(t) for t in timescales) + padded = np.full((len(timescales), width), np.nan) + for i, t in enumerate(timescales): + padded[i, : len(t)] = t + return { + "iterations": np.asarray(iters, dtype=int), + "vamp2": np.asarray(vamp2, dtype=float), + "timescales": padded, + } + + +def load_latest_msm(run_dir: str | Path) -> dict[str, np.ndarray] | None: + """Return the arrays of the most recent ``msm.npz`` (or None if absent).""" + for d in reversed(_iter_dirs(run_dir)): + f = d / "msm.npz" + if f.exists(): + with np.load(f, allow_pickle=True) as data: + return {k: data[k] for k in data.files} + return None + + +def load_cv_points(run_dir: str | Path) -> np.ndarray: + """Stack all per-iteration CV projections (``cvs.npz``) into one array.""" + chunks: list[np.ndarray] = [] + for d in _iter_dirs(run_dir): + f = d / "cvs.npz" + if not f.exists(): + continue + with np.load(f, allow_pickle=True) as data: + key = "cvs" if "cvs" in data.files else data.files[0] + arr = np.asarray(data[key], dtype=float) + if arr.ndim == 1: + arr = arr.reshape(-1, 1) + chunks.append(arr) + if not chunks: + return np.zeros((0, 0), dtype=float) + return np.vstack(chunks) + + +def free_energy_from_populations( + populations: np.ndarray, temperature: float = 300.0 +) -> np.ndarray: + """Relative free energy ``-kT ln(p)`` (kJ/mol), shifted so the min is 0.""" + p = np.asarray(populations, dtype=float) + with np.errstate(divide="ignore"): + f = -kT(temperature) * np.log(np.where(p > 0, p, np.nan)) + f = f - np.nanmin(f) + return f + + +def free_energy_surface( + points: np.ndarray, + bins: int = 60, + temperature: float = 300.0, +): + """2D free-energy surface ``F(x,y) = -kT ln P(x,y)`` from CV points. + + Returns ``(F, xedges, yedges)`` with ``F`` shifted to a 0 minimum and + unsampled cells set to NaN. Requires at least 2D points. + """ + points = np.asarray(points, dtype=float) + if points.ndim != 2 or points.shape[1] < 2: + raise ValueError("free_energy_surface needs points with >= 2 dimensions.") + hist, xedges, yedges = np.histogram2d( + points[:, 0], points[:, 1], bins=bins, density=True + ) + with np.errstate(divide="ignore"): + f = -kT(temperature) * np.log(np.where(hist > 0, hist, np.nan)) + f = f - np.nanmin(f) + return f.T, xedges, yedges diff --git a/autosampler/analysis/plots.py b/autosampler/analysis/plots.py new file mode 100644 index 0000000..d0d5427 --- /dev/null +++ b/autosampler/analysis/plots.py @@ -0,0 +1,185 @@ +"""Matplotlib plotting utilities for MSM analysis. + +Each function accepts an optional Axes and returns it, so plots compose into +custom figures; :func:`plot_convergence_report` assembles a standard multi-panel +summary for a run. matplotlib is an optional dependency (``autosampler[examples]``); +it is imported lazily with an actionable error if missing. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +from . import data as _data + + +def _plt(): + try: + import matplotlib + + matplotlib.use("Agg", force=False) + import matplotlib.pyplot as plt + + return plt + except ImportError as exc: # pragma: no cover - environment dependent + raise ImportError( + "Plotting requires matplotlib. Install with: " + 'pip install "autosampler[examples]".' + ) from exc + + +def _ax(ax): + if ax is not None: + return ax + return _plt().subplots(figsize=(5, 4))[1] + + +def plot_implied_timescales(lagtimes, timescales, ax=None): + """Implied timescales vs lag time (the ITS convergence plot).""" + ax = _ax(ax) + lagtimes = np.asarray(lagtimes, dtype=float) + timescales = np.asarray(timescales, dtype=float) + for j in range(timescales.shape[1]): + ax.plot(lagtimes, timescales[:, j], marker="o", label=f"t{j + 2}") + ax.fill_between(lagtimes, lagtimes, color="0.85", label="lag time") + ax.set_xlabel("lag time (frames)") + ax.set_ylabel("implied timescale (frames)") + ax.set_yscale("log") + ax.set_title("Implied timescales") + ax.legend(fontsize="small") + return ax + + +def plot_timescale_convergence(series, ax=None): + """Slowest implied timescales vs iteration.""" + ax = _ax(ax) + iters = series["iterations"] + ts = series["timescales"] + n = min(3, ts.shape[1]) if ts.size else 0 + for j in range(n): + ax.plot(iters, ts[:, j], marker="o", label=f"t{j + 2}") + ax.set_xlabel("iteration") + ax.set_ylabel("implied timescale (frames)") + ax.set_title("Timescale convergence") + if n: + ax.legend(fontsize="small") + return ax + + +def plot_vamp2_convergence(series, ax=None): + """VAMP-2 score vs iteration.""" + ax = _ax(ax) + ax.plot(series["iterations"], series["vamp2"], marker="o", color="C3") + ax.set_xlabel("iteration") + ax.set_ylabel("VAMP-2 score") + ax.set_title("VAMP-2 convergence") + return ax + + +def plot_free_energy_surface(points, bins=60, temperature=300.0, ax=None): + """Free-energy surface over the first two CV dimensions.""" + ax = _ax(ax) + f, xedges, yedges = _data.free_energy_surface(points, bins, temperature) + mesh = ax.pcolormesh(xedges, yedges, f, shading="auto", cmap="viridis") + ax.figure.colorbar(mesh, ax=ax, label="free energy (kJ/mol)") + ax.set_xlabel("CV 1") + ax.set_ylabel("CV 2") + ax.set_title("Free-energy surface") + return ax + + +def plot_metastable_free_energy(populations, temperature=300.0, ax=None): + """Bar chart of metastable-state free energies (from PCCA+ populations).""" + ax = _ax(ax) + f = _data.free_energy_from_populations(populations, temperature) + ax.bar(np.arange(len(f)), f, color="C0") + ax.set_xlabel("metastable state") + ax.set_ylabel("free energy (kJ/mol)") + ax.set_title("Metastable free energies") + return ax + + +def plot_msm_network(transition_matrix, stationary=None, ax=None, threshold=0.01): + """Draw the MSM as a network: node size ~ stationary weight, edges ~ T_ij. + + Uses a circular layout (no networkx dependency). + """ + ax = _ax(ax) + T = np.asarray(transition_matrix, dtype=float) + n = T.shape[0] + if stationary is None: + stationary = np.full(n, 1.0 / n) + stationary = np.asarray(stationary, dtype=float) + + angles = np.linspace(0, 2 * np.pi, n, endpoint=False) + pos = np.column_stack([np.cos(angles), np.sin(angles)]) + + for i in range(n): + for j in range(n): + if i != j and T[i, j] > threshold: + ax.annotate( + "", + xy=pos[j], + xytext=pos[i], + arrowprops=dict( + arrowstyle="->", + color="0.6", + alpha=min(1.0, float(T[i, j])), + lw=0.5 + 2.0 * float(T[i, j]), + ), + ) + sizes = 200 + 3000 * stationary / max(stationary.max(), 1e-9) + ax.scatter(pos[:, 0], pos[:, 1], s=sizes, c=np.arange(n), cmap="tab20", zorder=3) + for i in range(n): + ax.text(pos[i, 0], pos[i, 1], str(i), ha="center", va="center", zorder=4) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("MSM network") + return ax + + +def plot_convergence_report(run_dir, outfile=None, temperature=300.0): + """Assemble a standard multi-panel summary figure for a run. + + Panels: VAMP-2 convergence, timescale convergence, free-energy surface, and + (when available) the latest implied-timescale sweep / MSM network. Saves to + ``outfile`` (default ``/analysis/convergence_report.png``) and + returns the saved path. + """ + plt = _plt() + series = _data.load_msm_series(run_dir) + latest = _data.load_latest_msm(run_dir) + points = _data.load_cv_points(run_dir) + + fig, axes = plt.subplots(2, 2, figsize=(11, 9)) + plot_vamp2_convergence(series, ax=axes[0, 0]) + plot_timescale_convergence(series, ax=axes[0, 1]) + + if points.size and points.shape[1] >= 2: + plot_free_energy_surface(points, temperature=temperature, ax=axes[1, 0]) + else: + axes[1, 0].set_axis_off() + + if latest is not None and "its_lagtimes" in latest: + plot_implied_timescales( + latest["its_lagtimes"], latest["its_timescales"], ax=axes[1, 1] + ) + elif latest is not None and "transition_matrix" in latest: + plot_msm_network( + latest["transition_matrix"], + latest.get("stationary_distribution"), + ax=axes[1, 1], + ) + else: + axes[1, 1].set_axis_off() + + fig.tight_layout() + if outfile is None: + outfile = Path(run_dir) / "analysis" / "convergence_report.png" + outfile = Path(outfile) + outfile.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(outfile, dpi=150) + plt.close(fig) + return outfile diff --git a/autosampler/analysis_cli.py b/autosampler/analysis_cli.py new file mode 100644 index 0000000..69ad4aa --- /dev/null +++ b/autosampler/analysis_cli.py @@ -0,0 +1,47 @@ +"""CLI: generate MSM analysis figures from a finished/ongoing run. + + autosampler-analyze --run-dir runs/my_run [--outfile fig.png] [--temperature 300] +""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-dir", type=Path, required=True, help="Run output dir.") + parser.add_argument( + "--outfile", + type=Path, + default=None, + help="Figure path (default: /analysis/convergence_report.png).", + ) + parser.add_argument("--temperature", type=float, default=300.0) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> None: + args = parse_args(argv) + if not args.run_dir.is_dir(): + raise SystemExit(f"ERROR: run dir not found: {args.run_dir}") + + from autosampler.analysis.data import load_msm_series + from autosampler.analysis.plots import plot_convergence_report + + series = load_msm_series(args.run_dir) + if series["iterations"].size == 0: + raise SystemExit( + f"No msm.npz found under {args.run_dir}. Was the run started with " + "msm.enabled: true?" + ) + out = plot_convergence_report( + args.run_dir, outfile=args.outfile, temperature=args.temperature + ) + print(f"Wrote {out} ({series['iterations'].size} MSM iterations).") + + +if __name__ == "__main__": + main() diff --git a/autosampler/binning/__init__.py b/autosampler/binning/__init__.py index 96a1da1..381b94b 100644 --- a/autosampler/binning/__init__.py +++ b/autosampler/binning/__init__.py @@ -1,2 +1 @@ -from .we import WEResampler from .spatial import BinTable, RegularBinner, VoronoiBinner diff --git a/autosampler/binning/adaptive.py b/autosampler/binning/adaptive.py new file mode 100644 index 0000000..d5ff82e --- /dev/null +++ b/autosampler/binning/adaptive.py @@ -0,0 +1,257 @@ +"""Landscape-adaptive binning schemes. + +The default :class:`~autosampler.binning.spatial.RegularBinner` is a uniform grid: +constant bin width everywhere. Near a steep free-energy barrier a wide bin lets a +walker slide back before it can reach the next bin within the lag time, so the WE +flux across the barrier stalls; in flat basins fine bins waste replicas. These +schemes make the bins **landscape-adaptive** — finer where the landscape is steep +/ sparse, coarser where it is flat — and are recomputed every iteration. + +All binners share the :class:`~autosampler.binning.spatial.RegularBinner` API +(``fit(points) -> BinTable``), so the density / WE spawners consume them +interchangeably. ``uniform`` maps straight to ``RegularBinner`` for exact +backwards compatibility. + +Schemes +------- +- ``uniform`` : constant-width grid (``RegularBinner``). +- ``gradient`` : equi-resistance edges — boundaries at equal increments of + ``∫ exp(βF) dx ∝ ∫ 1/P dx`` so bins concentrate where the + sampled density is low (barriers / steep regions). +- ``mab`` : Minimal-Adaptive-Binning-style — uniform bins between the + occupied extremes plus narrow "foothold" bins at the moving + fronts. +- ``eigenvector`` : bin uniformly along the leading (slowest) CV coordinate + only; for a learned CV / committor proxy this is automatically + fine across the barrier and coarse in basins. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +import numpy as np + +from .spatial import BinTable, RegularBinner, padded_bounds + + +def _smooth(values: np.ndarray, window: int) -> np.ndarray: + if window <= 1 or len(values) < window: + return values + kernel = np.ones(window) / window + return np.convolve(values, kernel, mode="same") + + +def _dedupe_increasing(edges: np.ndarray, lo: float, hi: float) -> np.ndarray: + """Force strictly-increasing edges spanning [lo, hi].""" + edges = np.asarray(edges, dtype=float) + edges[0], edges[-1] = lo, hi + edges = np.maximum.accumulate(edges) + # nudge any duplicates apart so np.searchsorted yields non-empty bins + eps = (hi - lo) * 1e-9 + 1e-12 + for i in range(1, len(edges)): + if edges[i] <= edges[i - 1]: + edges[i] = edges[i - 1] + eps + return edges + + +def _grid_bintable(coords, edges, target=None) -> BinTable: + """Bucket ``coords`` into a variable-width grid defined by per-axis ``edges``.""" + coords = np.asarray(coords, dtype=float) + n_axes = len(edges) + nbin = [max(len(e) - 1, 1) for e in edges] + + idx = np.empty((len(coords), n_axes), dtype=int) + for a in range(n_axes): + pos = np.searchsorted(edges[a], coords[:, a], side="right") - 1 + idx[:, a] = np.clip(pos, 0, nbin[a] - 1) + + ids = list(np.ndindex(*nbin)) + id_to_row = {t: i for i, t in enumerate(ids)} + populations = np.zeros(len(ids), dtype=int) + populated_data: list[list[int]] = [[] for _ in ids] + for frame, cell in enumerate(map(tuple, idx)): + row = id_to_row[cell] + populations[row] += 1 + populated_data[row].append(frame) + + centers = np.array( + [ + [0.5 * (edges[a][t[a]] + edges[a][t[a] + 1]) for a in range(n_axes)] + for t in ids + ], + dtype=float, + ) + + target_closeness = None + if target is not None: + tt = np.asarray(target, dtype=float)[:n_axes] + dist = np.linalg.norm(centers - tt, axis=1) + denom = dist.max() - dist.min() + target_closeness = ( + (dist.max() - dist) / denom if denom > 1e-12 else np.ones_like(dist) + ) + + return BinTable( + ids=ids, + centers=centers, + populations=populations, + populated_data=populated_data, + target_closeness=target_closeness, + ) + + +class AdaptiveBinner(ABC): + """Base class: per-axis adaptive edges over a (padded) bounding box.""" + + def __init__( + self, + n_bins, + min_values=None, + max_values=None, + target=None, + padding: float = 0.1, + **_, + ): + self.n_bins = np.asarray(n_bins, dtype=int) + self.min_values = None if min_values is None else np.asarray(min_values, float) + self.max_values = None if max_values is None else np.asarray(max_values, float) + self.target = None if target is None else np.asarray(target, float) + self.padding = float(padding) + + def _bounds(self, points: np.ndarray): + if self.min_values is None or self.max_values is None: + lo, hi = padded_bounds(points, self.padding) + lo = lo if self.min_values is None else self.min_values + hi = hi if self.max_values is None else self.max_values + else: + lo, hi = self.min_values, self.max_values + return np.asarray(lo, float), np.asarray(hi, float) + + def _coords(self, points: np.ndarray): + """Columns of ``points`` to bin and the per-axis bin counts.""" + return points, [int(b) for b in self.n_bins] + + @abstractmethod + def _axis_edges(self, col: np.ndarray, lo: float, hi: float, nb: int) -> np.ndarray: + """Return ``nb``-ish increasing edges for one coordinate.""" + + def fit(self, points: np.ndarray) -> BinTable: + points = np.asarray(points, dtype=float) + if points.ndim != 2: + raise ValueError("AdaptiveBinner expects a 2D array of points.") + coords, nbin = self._coords(points) + lo, hi = self._bounds(points) + edges = [] + for a in range(coords.shape[1]): + lo_a = float(lo[a]) if a < len(lo) else float(coords[:, a].min()) + hi_a = float(hi[a]) if a < len(hi) else float(coords[:, a].max()) + if hi_a <= lo_a: + hi_a = lo_a + 1e-9 + edges.append( + _dedupe_increasing( + self._axis_edges(coords[:, a], lo_a, hi_a, int(nbin[a])), lo_a, hi_a + ) + ) + return _grid_bintable(coords, edges, self.target) + + +class GradientBinner(AdaptiveBinner): + """Equi-resistance edges: dense where the sampled density is low (barriers).""" + + def __init__(self, *args, n_fine: int = 100, smoothing: int = 3, **kw): + super().__init__(*args, **kw) + self.n_fine = int(n_fine) + self.smoothing = int(smoothing) + + def _axis_edges(self, col, lo, hi, nb): + n_fine = max(self.n_fine, nb * 4) + hist, fine = np.histogram(col, bins=n_fine, range=(lo, hi)) + density = _smooth(hist.astype(float), self.smoothing) + 1e-9 + resistance = 1.0 / density # ∝ exp(βF) + cum = np.concatenate([[0.0], np.cumsum(resistance * np.diff(fine))]) + if cum[-1] <= 0: + return np.linspace(lo, hi, nb + 1) + targets = np.linspace(0.0, cum[-1], nb + 1) + return np.interp(targets, cum, fine) + + +class MABinner(AdaptiveBinner): + """Minimal-Adaptive-Binning style: uniform middle + narrow front footholds.""" + + def _axis_edges(self, col, lo, hi, nb): + occ_lo, occ_hi = float(col.min()), float(col.max()) + if nb < 4 or occ_hi <= occ_lo: + return np.linspace(lo, hi, nb + 1) + inner = np.linspace(occ_lo, occ_hi, nb - 1) + # Split the two outermost occupied bins to give footholds at the fronts. + foot_lo = 0.5 * (inner[0] + inner[1]) + foot_hi = 0.5 * (inner[-2] + inner[-1]) + return np.unique( + np.concatenate( + [[lo], inner[:1], [foot_lo], inner[1:-1], [foot_hi], inner[-1:], [hi]] + ) + ) + + +class EigenvectorBinner(AdaptiveBinner): + """Bin uniformly along the leading (slowest) CV coordinate only. + + For a learned CV / committor-proxy the slow coordinate compresses basins and + stretches the barrier, so uniform bins in it are automatically fine across the + barrier and coarse in basins. ``coordinate`` selects the column (default 0). + """ + + def __init__(self, *args, coordinate: int = 0, **kw): + super().__init__(*args, **kw) + self.coordinate = int(coordinate) + + def _coords(self, points): + col = self.coordinate if self.coordinate < points.shape[1] else 0 + return points[:, [col]], [int(self.n_bins[0])] + + def _axis_edges(self, col, lo, hi, nb): + return np.linspace(lo, hi, nb + 1) + + +class BinnerFactory: + _binners = { + "gradient": GradientBinner, + "mab": MABinner, + "eigenvector": EigenvectorBinner, + } + + @classmethod + def register(cls, name: str, binner_cls) -> None: + cls._binners[name] = binner_cls + + @classmethod + def available(cls) -> list[str]: + return sorted(["uniform", *cls._binners]) + + +def make_binner( + scheme: str, + *, + n_bins, + min_values=None, + max_values=None, + target=None, + n_fine: int = 100, + smoothing: int = 3, +): + """Construct the configured binner. ``uniform`` → ``RegularBinner`` (exact).""" + if scheme == "uniform": + return RegularBinner( + n_bins=n_bins, min_values=min_values, max_values=max_values, target=target + ) + if scheme not in BinnerFactory._binners: + raise ValueError( + f"Unknown binning scheme {scheme!r}; available: {BinnerFactory.available()}" + ) + kwargs = dict( + n_bins=n_bins, min_values=min_values, max_values=max_values, target=target + ) + if scheme == "gradient": + kwargs.update(n_fine=n_fine, smoothing=smoothing) + return BinnerFactory._binners[scheme](**kwargs) diff --git a/autosampler/binning/we.py b/autosampler/binning/we.py index 73d71de..11f3714 100644 --- a/autosampler/binning/we.py +++ b/autosampler/binning/we.py @@ -1,25 +1,111 @@ -class WEResampler: - """Manages walker splitting and merging while conserving probability weights.""" - - def __init__(self, target_walkers_per_bin: int = 4): - self.target_count = target_walkers_per_bin - - def resample(self, bins_dict: dict) -> list: - """Splits high-weight walkers and merges low-weight walkers.""" - resampled_walkers = [] - for bin_id, walkers in bins_dict.items(): - if len(walkers) == 0: - continue - - # Walkers should have a weight attribute, assume default of 1.0 if not present - total_weight = sum(getattr(w, 'weight', 1.0) for w in walkers) - - # Placeholder for split / merge logic - # High-weight walkers are split, dividing weight equally. - # Low-weight walkers undergo Monte Carlo merging. - # Conserves sum(weights) = total_weight - - # Currently just passing through the walkers for backward compatibility - resampled_walkers.extend(walkers) - - return resampled_walkers +"""Weighted-ensemble (WE) resampling. + +Implements the split/merge resampling of Huber & Kim (1996): walkers carry +statistical weights and are kept at a target count per bin while **total weight +is conserved**. Under-represented bins gain walkers by *splitting* high-weight +walkers (weight divided among copies); over-represented bins lose walkers by +*merging* low-weight walkers (weights summed, one survivor chosen with +probability proportional to weight). This focuses sampling on bins/regions +without biasing the estimated probabilities. + +The core operates on plain arrays (weights + bin labels), so it is independent +of the binning implementation and fully unit-testable. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass +class ResampleResult: + """Outcome of one WE resampling step. + + ``parents`` are indices into the *input* ensemble (a value may repeat when a + walker was split); ``weights`` are the matching statistical weights. Total + weight equals the input total (up to floating point). + """ + + parents: list[int] + weights: list[float] + + def __len__(self) -> int: + return len(self.parents) + + +class WeightedEnsemble: + """Split/merge resampler that conserves probability weight. + + Parameters + ---------- + target_per_bin: + Desired number of walkers in each occupied bin after resampling. + """ + + def __init__(self, target_per_bin: int = 4): + if target_per_bin < 1: + raise ValueError("target_per_bin must be >= 1") + self.target_per_bin = int(target_per_bin) + + def resample( + self, + weights, + bin_labels, + target_per_bin: int | None = None, + rng: np.random.Generator | None = None, + ) -> ResampleResult: + """Resample walkers to ``target_per_bin`` per occupied bin. + + ``weights[i]`` and ``bin_labels[i]`` describe walker ``i``. Returns the + post-resampling ensemble as parent indices + weights. + """ + weights = np.asarray(weights, dtype=float) + labels = np.asarray(bin_labels) + if weights.shape[0] != labels.shape[0]: + raise ValueError("weights and bin_labels must have equal length") + target = self.target_per_bin if target_per_bin is None else int(target_per_bin) + if target < 1: + raise ValueError("target_per_bin must be >= 1") + rng = np.random.default_rng() if rng is None else rng + + parents_out: list[int] = [] + weights_out: list[float] = [] + for label in np.unique(labels): + idx = np.flatnonzero(labels == label) + members = [int(i) for i in idx] # parent index per current walker + mweights = [float(weights[i]) for i in idx] + members, mweights = self._merge(members, mweights, target, rng) + members, mweights = self._split(members, mweights, target) + parents_out.extend(members) + weights_out.extend(mweights) + return ResampleResult(parents_out, weights_out) + + @staticmethod + def _merge(members, mweights, target, rng): + """Merge the two lowest-weight walkers until ``target`` remain.""" + while len(members) > target: + order = np.argsort(mweights) + i, j = int(order[0]), int(order[1]) + combined = mweights[i] + mweights[j] + # Survivor chosen with probability proportional to weight. + prob_i = mweights[i] / combined if combined > 0 else 0.5 + survivor = i if rng.random() < prob_i else j + keep_parent = members[survivor] + members = [m for k, m in enumerate(members) if k not in (i, j)] + mweights = [w for k, w in enumerate(mweights) if k not in (i, j)] + members.append(keep_parent) + mweights.append(combined) + return members, mweights + + @staticmethod + def _split(members, mweights, target): + """Split the highest-weight walker until ``target`` walkers exist.""" + while 0 < len(members) < target: + k = int(np.argmax(mweights)) + half = mweights[k] / 2.0 + mweights[k] = half + members.append(members[k]) + mweights.append(half) + return members, mweights diff --git a/autosampler/checkpoints/manager.py b/autosampler/checkpoints/manager.py index d762074..7d74e77 100644 --- a/autosampler/checkpoints/manager.py +++ b/autosampler/checkpoints/manager.py @@ -1,8 +1,17 @@ +import logging import pickle import torch from pathlib import Path from typing import Any, Dict, Tuple +# On-disk checkpoint format version. Bump when the layout changes; ``load`` +# tolerates older checkpoints (a missing version file is treated as v1). +CHECKPOINT_FORMAT_VERSION = 2 + +# CV methods whose projection network lives in ``space_model.fitted`` as a +# torch module and is additionally snapshotted as ``model.pt``. +_TORCH_ENCODER_MODES = ("tvae", "vampnet", "spib") + class CheckpointManager: """Handles serialization and reconstruction of the sampler state to allow exact deterministic restarts.""" @@ -23,7 +32,8 @@ def save( """Save a complete state snapshot.""" iter_dir = self.checkpoint_dir / f"iter_{iteration}" iter_dir.mkdir(exist_ok=True) - + (iter_dir / "format_version").write_text(str(CHECKPOINT_FORMAT_VERSION)) + # 1. Save Space Model (TVAE or TICA) if space_model is not None: with open(iter_dir / "space_model.pkl", "wb") as f: @@ -31,10 +41,10 @@ def save( if hasattr(space_model, "type"): if ( - space_model.type == "tvae" - and getattr(space_model, "fited", None) is not None + space_model.type in _TORCH_ENCODER_MODES + and getattr(space_model, "fitted", None) is not None ): - torch.save(space_model.fited.state_dict(), iter_dir / "model.pt") + torch.save(space_model.fitted.state_dict(), iter_dir / "model.pt") elif ( space_model.type == "tica" and getattr(space_model, "model", None) is not None @@ -49,8 +59,15 @@ def save( # 3. Save Bins & Spawn History with open(iter_dir / "bin_state.pkl", "wb") as f: pickle.dump(bin_state, f) + + # Delta Checkpointing: Only save history since the last checkpoint + last_ckpt = self._get_latest_checkpoint_before(iteration) + delta_history = { + k: v for k, v in history.items() if k > last_ckpt and k <= iteration + } with open(iter_dir / "history.pkl", "wb") as f: - pickle.dump(history, f) + pickle.dump(delta_history, f) + if sampler_state is not None: with open(iter_dir / "sampler_state.pkl", "wb") as f: pickle.dump(sampler_state, f) @@ -64,19 +81,20 @@ def load( raise FileNotFoundError( f"Checkpoint for iteration {iteration} not found at {iter_dir}" ) + self._check_format_version(iter_dir) # 1. Load model weights if (iter_dir / "space_model.pkl").exists(): with open(iter_dir / "space_model.pkl", "rb") as f: space_model = pickle.load(f) elif hasattr(space_model, "type"): - if space_model.type == "tvae": + if space_model.type in _TORCH_ENCODER_MODES: if not (iter_dir / "model.pt").exists(): raise FileNotFoundError( - f"TVAE model checkpoint missing in {iter_dir}" + f"{space_model.type} model checkpoint missing in {iter_dir}" ) - space_model.fited.load_state_dict(torch.load(iter_dir / "model.pt")) - space_model.fited.eval() + space_model.fitted.load_state_dict(torch.load(iter_dir / "model.pt")) + space_model.fitted.eval() elif space_model.type == "tica": if not (iter_dir / "model.pkl").exists(): raise FileNotFoundError( @@ -92,9 +110,32 @@ def load( # 3. Load bins & history with open(iter_dir / "bin_state.pkl", "rb") as f: bin_state = pickle.load(f) + with open(iter_dir / "history.pkl", "rb") as f: history = pickle.load(f) + # Reconstruct full history for Delta Checkpointing + checkpoint_dirs = [ + path + for path in self.checkpoint_dir.glob("iter_*") + if path.is_dir() and path.name.removeprefix("iter_").isdigit() + ] + previous_iters = sorted([ + int(path.name.removeprefix("iter_")) + for path in checkpoint_dirs + if int(path.name.removeprefix("iter_")) < iteration + ], reverse=True) + + for prev_iter in previous_iters: + prev_hist_file = self.checkpoint_dir / f"iter_{prev_iter}" / "history.pkl" + if prev_hist_file.exists(): + with open(prev_hist_file, "rb") as f: + part_hist = pickle.load(f) + if isinstance(part_hist, dict): + for k, v in part_hist.items(): + if k not in history: + history[k] = v + state_path = iter_dir / "sampler_state.pkl" if state_path.exists(): with open(state_path, "rb") as f: @@ -104,6 +145,24 @@ def load( return space_model, scaler, bin_state, history, sampler_state + @staticmethod + def _check_format_version(iter_dir: Path) -> None: + version_file = iter_dir / "format_version" + version = 1 + if version_file.exists(): + try: + version = int(version_file.read_text().strip()) + except ValueError: + version = 1 + if version > CHECKPOINT_FORMAT_VERSION: + logging.warning( + "Checkpoint %s was written with format version %d, newer than the " + "supported version %d; restore may be incomplete.", + iter_dir, + version, + CHECKPOINT_FORMAT_VERSION, + ) + def latest_iteration(self) -> int: checkpoint_dirs = [ path @@ -115,3 +174,16 @@ def latest_iteration(self) -> int: f"No checkpoints found under {self.checkpoint_dir}" ) return max(int(path.name.removeprefix("iter_")) for path in checkpoint_dirs) + + def _get_latest_checkpoint_before(self, iteration: int) -> int | float: + checkpoint_dirs = [ + path + for path in self.checkpoint_dir.glob("iter_*") + if path.is_dir() and path.name.removeprefix("iter_").isdigit() + ] + previous_iters = sorted([ + int(path.name.removeprefix("iter_")) + for path in checkpoint_dirs + if int(path.name.removeprefix("iter_")) < iteration + ], reverse=True) + return previous_iters[0] if previous_iters else -float('inf') diff --git a/autosampler/cli.py b/autosampler/cli.py index 90a0806..d00aaff 100644 --- a/autosampler/cli.py +++ b/autosampler/cli.py @@ -7,10 +7,13 @@ import logging import os import sys +import tempfile from pathlib import Path from typing import Any, Sequence -os.environ.setdefault("MPLCONFIGDIR", "/tmp/autosampler-matplotlib") +os.environ.setdefault( + "MPLCONFIGDIR", os.path.join(tempfile.gettempdir(), "autosampler-matplotlib") +) SYSTEM_PATH_KEYS = ( "conf_file", @@ -77,9 +80,7 @@ def run( f"iter_{checkpoint_iteration}; next iteration is {sampler.iteration}." ) else: - walkers = [ - sampler.engine.positions for _ in range(sampler.config.spawning.walker) - ] + walkers = sampler.generate_initial_walkers() completed_iterations = 0 for iteration in range(iterations): diff --git a/autosampler/config.py b/autosampler/config.py index c839841..6c6d2a4 100644 --- a/autosampler/config.py +++ b/autosampler/config.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List, Optional, Union -from pydantic import BaseModel, Field, root_validator, validator +from pydantic import BaseModel, Field, field_validator, model_validator class SystemConfig(BaseModel): @@ -9,6 +9,7 @@ class SystemConfig(BaseModel): topology: str = "amber" system_file: Optional[str] = None project_file: Optional[str] = None + initial_trajectory: Optional[str] = None trajectory_topology_file: Optional[str] = None feature_selection: str = "protein and not (type H)" @@ -38,7 +39,8 @@ class EngineConfig(BaseModel): amber_extra_args: List[str] = [] amber_trajectory_format: str = "auto" - @validator("gpu_ids") + @field_validator("gpu_ids") + @classmethod def validate_gpu_ids(cls, value: Optional[List[int]]) -> Optional[List[int]]: if value is None: return None @@ -50,7 +52,8 @@ def validate_gpu_ids(cls, value: Optional[List[int]]) -> Optional[List[int]]: raise ValueError("gpu_ids must not contain duplicates") return value - @validator("amber_trajectory_format") + @field_validator("amber_trajectory_format") + @classmethod def validate_amber_trajectory_format(cls, value: str) -> str: value = value.lower() if value not in {"auto", "netcdf", "ascii"}: @@ -75,6 +78,7 @@ class SpawningConfig(BaseModel): voronoi_periodic: bool = False voronoi_grid_size: int = 250 lof_neighbors: int = 20 + we_target_per_bin: int = 4 # weighted-ensemble walkers per occupied bin resolution_check_patience: int = 5 resolution_max_bins: int = 150 voronoi_max_clusters: int = 5000 @@ -91,14 +95,19 @@ class AdaptiveModelConfig(BaseModel): decoder_hidden_dims: List[int] = [128, 256] dropout_rate: float = 0.1 deep_tica_hidden_dims: List[int] = [256, 128] + # SPIB (State Predictive Information Bottleneck) hyperparameters. + spib_n_states: int = 10 + spib_beta: float = 1e-3 - @validator("lagtime", "latent_dim", "epochs") + @field_validator("lagtime", "latent_dim", "epochs") + @classmethod def validate_positive_int(cls, value: int) -> int: if value <= 0: raise ValueError("must be greater than 0") return value - @validator("batch_size") + @field_validator("batch_size") + @classmethod def validate_batch_size(cls, value: Union[int, str]) -> Union[int, str]: if isinstance(value, str): if value != "auto": @@ -108,19 +117,22 @@ def validate_batch_size(cls, value: Union[int, str]) -> Union[int, str]: raise ValueError("batch_size must be 'auto' or a positive integer") return value - @validator("learning_rate") + @field_validator("learning_rate") + @classmethod def validate_learning_rate(cls, value: float) -> float: if value <= 0: raise ValueError("learning_rate must be greater than 0") return value - @validator("dropout_rate") + @field_validator("dropout_rate") + @classmethod def validate_dropout_rate(cls, value: float) -> float: if value < 0 or value >= 1: raise ValueError("dropout_rate must be >= 0 and < 1") return value - @validator("encoder_hidden_dims", "decoder_hidden_dims", "deep_tica_hidden_dims") + @field_validator("encoder_hidden_dims", "decoder_hidden_dims", "deep_tica_hidden_dims") + @classmethod def validate_hidden_dims(cls, value: List[int]) -> List[int]: if not value: raise ValueError("hidden dimension lists must not be empty") @@ -129,10 +141,203 @@ def validate_hidden_dims(cls, value: List[int]) -> List[int]: return value +class MSMConfig(BaseModel): + """Configuration for Markov State Model estimation and MSM-based convergence. + + All MSM behaviour is opt-in: with ``enabled=False`` (the default) the + adaptive loop keeps its legacy bin-occupancy convergence and no MSM is built, + so existing configs and examples are unaffected. + """ + + enabled: bool = False + # How often (in iterations) to (re)estimate the MSM. 1 = every iteration. + cadence: int = 1 + # Minimum cumulative frames before the first MSM is attempted. + min_frames: int = 1000 + lagtime: int = 10 + # Optional lag-time ladder for an implied-timescale sweep (diagnostics). + lagtimes: Optional[List[int]] = None + n_microstates: int = 100 + cluster_method: str = "kmeans" # "kmeans" | "regspace" + estimator: str = "mle" # "mle" | "bayesian" + n_bayesian_samples: int = 50 + n_timescales: int = 3 + n_metastable: Optional[int] = None + # Keep microstate IDs comparable across iterations (seeds k-means from the + # previous centres) — needed for transition-matrix convergence / spawning. + stable_clustering: bool = False + # MSM-guided spawner (spawn_scheme: msm) knobs. + spawn_alpha: float = 1.0 # weight of the least-counts/exploration term + spawn_leverage: int = 1 # slow eigenvectors used for the leverage factor + spawn_uncertainty: bool = True # include the outflow-uncertainty factor + # Convergence: list of {name, params} criteria combined with all/any. + convergence_criteria: List[Dict[str, Any]] = Field( + default_factory=lambda: [ + {"name": "implied_timescales", "params": {"tol": 0.1, "n_timescales": 2}}, + {"name": "vamp2", "params": {"tol": 0.05}}, + ] + ) + convergence_mode: str = "all" # "all" | "any" + convergence_patience: int = 2 + + @field_validator("cadence", "lagtime", "n_microstates", "n_timescales") + @classmethod + def _positive(cls, value: int) -> int: + if value <= 0: + raise ValueError("must be greater than 0") + return value + + @field_validator("cluster_method") + @classmethod + def _cluster_method(cls, value: str) -> str: + value = value.lower() + if value not in {"kmeans", "regspace"}: + raise ValueError("cluster_method must be 'kmeans' or 'regspace'") + return value + + @field_validator("estimator") + @classmethod + def _estimator(cls, value: str) -> str: + value = value.lower() + if value not in {"mle", "bayesian"}: + raise ValueError("estimator must be 'mle' or 'bayesian'") + return value + + @field_validator("convergence_mode") + @classmethod + def _mode(cls, value: str) -> str: + value = value.lower() + if value not in {"all", "any"}: + raise ValueError("convergence_mode must be 'all' or 'any'") + return value + + +class FeatureSelectionConfig(BaseModel): + """VAMP-2 based selection/optimisation of the input features for the CV/MSM. + + Opt-in (``enabled=False`` by default). When enabled, the adaptive loop + periodically scores feature columns by VAMP-2 and keeps the subset that best + resolves the slow dynamics, adaptively updating it every ``cadence`` + iterations. + """ + + enabled: bool = False + method: str = "greedy_vamp" # "greedy_vamp" | "all" + lagtime: int = 10 + cadence: int = 5 # re-select every N iterations (adaptive update) + max_features: Optional[int] = None # cap on selected columns/groups + dim: Optional[int] = None # singular values retained when scoring + min_gain: float = 1e-4 # minimum VAMP-2 gain to add a feature group + # Optional: rank these feature *types* by VAMP-2 and use the best one. + # Empty -> always use the top-level `adaptive_feature_type`. + candidate_feature_types: List[str] = [] + + @field_validator("method") + @classmethod + def _method(cls, value: str) -> str: + if value not in {"greedy_vamp", "all"}: + raise ValueError("feature_selection.method must be 'greedy_vamp' or 'all'") + return value + + @field_validator("candidate_feature_types") + @classmethod + def _candidate_types(cls, value: List[str]) -> List[str]: + valid = {"distances", "fitted_coords", "phi_psi"} + bad = [v for v in value if v not in valid] + if bad: + raise ValueError( + "feature_selection.candidate_feature_types must be a subset of " + f"{sorted(valid)}; got invalid {bad}" + ) + return value + + @field_validator("lagtime", "cadence") + @classmethod + def _positive(cls, value: int) -> int: + if value <= 0: + raise ValueError("must be greater than 0") + return value + + +class ExecutionConfig(BaseModel): + """Where and how walker MD jobs are dispatched. + + ``backend: local`` (default) runs walkers as local subprocesses across CPU + or GPU slots (multi-GPU workstation). ``slurm`` / ``pbs`` submit each + iteration's walkers as a scheduler array job for CPU-only or GPU HPC + clusters. Scheduler fields are ignored by the local backend. + """ + + backend: str = "local" # "local" | "slurm" | "pbs" + # Scheduler resource requests (per array task = one walker). + partition: Optional[str] = None # SLURM partition / PBS queue + account: Optional[str] = None + walltime: str = "01:00:00" + cpus_per_task: int = 1 + gpus_per_task: int = 0 + memory: Optional[str] = None # e.g. "8G" + # Robustness / polling. + max_retries: int = 1 # resubmit failed walkers up to this many times + poll_interval: float = 30.0 # seconds between scheduler polls + submit_timeout: float = 60.0 # seconds for a submit/poll command + module_loads: List[str] = [] # `module load ...` lines for job scripts + extra_directives: List[str] = [] # raw #SBATCH / #PBS lines + job_name: str = "autosampler" + + @field_validator("backend") + @classmethod + def _backend(cls, value: str) -> str: + value = value.lower() + if value not in {"local", "slurm", "pbs"}: + raise ValueError("execution.backend must be 'local', 'slurm', or 'pbs'") + return value + + @field_validator("cpus_per_task", "max_retries") + @classmethod + def _non_negative_int(cls, value: int) -> int: + if value < 0: + raise ValueError("must be >= 0") + return value + + @field_validator("poll_interval", "submit_timeout") + @classmethod + def _positive_float(cls, value: float) -> float: + if value <= 0: + raise ValueError("must be > 0") + return value + + +class BinningConfig(BaseModel): + """Landscape-adaptive binning for the density / weighted-ensemble spawners. + + ``uniform`` (default) is the constant-width grid (backward-compatible). The + adaptive schemes make bins finer in steep / low-density regions (barriers) and + coarser in flat basins, recomputed every iteration. + """ + + scheme: str = "uniform" # uniform | gradient | mab | eigenvector + n_fine: int = 100 # histogram resolution for the gradient scheme + smoothing: int = 3 # density smoothing window for the gradient scheme + + @field_validator("scheme") + @classmethod + def _scheme(cls, value: str) -> str: + valid = {"uniform", "gradient", "mab", "eigenvector"} + if value not in valid: + raise ValueError(f"binning.scheme must be one of {sorted(valid)}") + return value + + class AutoSamplerConfig(BaseModel): system: SystemConfig engine: EngineConfig spawning: SpawningConfig + msm: MSMConfig = Field(default_factory=MSMConfig) + binning: BinningConfig = Field(default_factory=BinningConfig) + execution: ExecutionConfig = Field(default_factory=ExecutionConfig) + feature_selection: FeatureSelectionConfig = Field( + default_factory=FeatureSelectionConfig + ) space_mode: str = "fixed" n_bins: List[int] = [30, 30] min_values: Optional[List[float]] = None @@ -142,12 +347,36 @@ class AutoSamplerConfig(BaseModel): checkpoint_freq: int = 1 save_features: bool = True retrain_freq: int = 1 + # CV-retraining policy: "fixed" (every retrain_freq iters) or "vamp_adaptive" + # (retrain when the CV's VAMP-2 score drops by vamp_retrain_tol). + retrain_policy: str = "fixed" + vamp_retrain_tol: float = 0.1 + retrain_min_interval: int = 1 + retrain_max_interval: Optional[int] = None aggregate_memory: bool = True max_adaptive_memory_frames: int = 50000 adaptive_feature_type: str = "distances" adaptive_model: AdaptiveModelConfig = Field(default_factory=AdaptiveModelConfig) - @root_validator(pre=True) + @field_validator("retrain_policy") + @classmethod + def _retrain_policy(cls, value: str) -> str: + if value not in {"fixed", "vamp_adaptive"}: + raise ValueError("retrain_policy must be 'fixed' or 'vamp_adaptive'") + return value + + @field_validator("space_mode") + @classmethod + def validate_space_mode(cls, value: str) -> str: + from autosampler.spaces.registry import FIXED_MODE, adaptive_modes + + valid = (FIXED_MODE,) + adaptive_modes() + if value not in valid: + raise ValueError(f"space_mode must be one of {valid}; got {value!r}") + return value + + @model_validator(mode="before") + @classmethod def promote_spawning_n_bins(cls, values: Dict[str, Any]) -> Dict[str, Any]: values = dict(values) if "n_bins" not in values: diff --git a/autosampler/core.py b/autosampler/core.py index 622686b..5b1f348 100644 --- a/autosampler/core.py +++ b/autosampler/core.py @@ -4,6 +4,7 @@ import importlib.util import json import shutil +import sys # Suppress common non-critical warnings from dependencies warnings.filterwarnings( @@ -16,13 +17,14 @@ import numpy as np from pydantic import ValidationError -from autosampler.binning.we import WEResampler from autosampler.checkpoints.manager import CheckpointManager from autosampler.config import AutoSamplerConfig from autosampler.engines.amber import amber_trajectory_suffix from autosampler.engines.base import EngineFactory from autosampler.paths import build_frame_records, map_global_frame +from autosampler.reporting import IterationReporter from autosampler.spaces import AdaptiveSpaceModel, FeatureExtractor +from autosampler.spaces.registry import is_adaptive_space from autosampler.spawners.base import SpawnerFactory from autosampler.utils.seeds import SeedManager from autosampler.workflows.parallel import run_iteration_parallel @@ -50,13 +52,16 @@ def __init__(self, config_dict: Dict[str, Any]): # Initialize Checkpoint Manager self.checkpoint_manager = CheckpointManager(str(self.outdir / "checkpoints")) + # Terminal progress reporter (presentation only). + self.reporter = IterationReporter() + # Initialize Engine _engine_kwargs = { - k: v for k, v in self.config.engine.dict().items() if k != "md_engine" + k: v for k, v in self.config.engine.model_dump().items() if k != "md_engine" } self.engine = EngineFactory.get(self.config.engine.md_engine, **_engine_kwargs) - is_adaptive = self.config.space_mode in ["tvae", "tica", "deep-tica", "pca"] + is_adaptive = is_adaptive_space(self.config.space_mode) # Initialize Spawner self.spawner = SpawnerFactory.get( self.config.spawning.spawn_scheme, @@ -71,10 +76,34 @@ def __init__(self, config_dict: Dict[str, Any]): periodic=self.config.spawning.voronoi_periodic, grid_size=self.config.spawning.voronoi_grid_size, n_neighbors=self.config.spawning.lof_neighbors, + target_per_bin=self.config.spawning.we_target_per_bin, + alpha=self.config.msm.spawn_alpha, + leverage=self.config.msm.spawn_leverage, + uncertainty=self.config.msm.spawn_uncertainty, + seed=self.config.random_seed, ) - # Initialize Resampler - self.resampler = WEResampler() + # Landscape-adaptive binning (opt-in via config.binning.scheme); supplied + # to the density / WE spawners. `uniform` leaves the spawners on the grid. + binning_cfg = getattr(self.config, "binning", None) + if ( + binning_cfg is not None + and binning_cfg.scheme != "uniform" + and hasattr(self.spawner, "binner") + ): + from autosampler.binning.adaptive import make_binner + + self.spawner.binner = make_binner( + binning_cfg.scheme, + n_bins=self.config.n_bins, + min_values=None if is_adaptive else self.config.min_values, + max_values=None if is_adaptive else self.config.max_values, + target=self.config.spawning.target + if self.config.spawning.search_mode == "target" + else None, + n_fine=binning_cfg.n_fine, + smoothing=binning_cfg.smoothing, + ) # State variables self.iteration = 0 @@ -91,6 +120,56 @@ def __init__(self, config_dict: Dict[str, Any]): self.converged = False self.convergence_reason = None + # VAMP-2 input-feature selection (opt-in via config.feature_selection). + self.feature_selector = None + self.feature_selection_indices = None + self.last_feature_selection = None + self.selected_feature_type = None + fs_cfg = getattr(self.config, "feature_selection", None) + if fs_cfg is not None and fs_cfg.enabled: + from autosampler.spaces.feature_selection import FeatureSelector + + self.feature_selector = FeatureSelector( + lagtime=fs_cfg.lagtime, + method=fs_cfg.method, + max_features=fs_cfg.max_features, + dim=fs_cfg.dim, + min_gain=fs_cfg.min_gain, + ) + + # Adaptive CV-retraining policy (VAMP-2 driven when configured). + from autosampler.spaces.retraining import RetrainController + + self.retrain_controller = RetrainController( + policy=self.config.retrain_policy, + retrain_freq=self.config.retrain_freq, + vamp_tol=self.config.vamp_retrain_tol, + min_interval=self.config.retrain_min_interval, + max_interval=self.config.retrain_max_interval, + ) + + # MSM subsystem (opt-in via config.msm.enabled). + self.msm_estimator = None + self.msm_monitor = None + self.last_msm_result = None + msm_cfg = getattr(self.config, "msm", None) + if msm_cfg is not None and msm_cfg.enabled: + from autosampler.msm import MSMEstimator, build_monitor_from_config + + self.msm_estimator = MSMEstimator( + lagtime=msm_cfg.lagtime, + n_microstates=msm_cfg.n_microstates, + cluster_method=msm_cfg.cluster_method, + estimator=msm_cfg.estimator, + n_metastable=msm_cfg.n_metastable, + n_timescales=msm_cfg.n_timescales, + lagtimes=msm_cfg.lagtimes, + n_bayesian_samples=msm_cfg.n_bayesian_samples, + stable_clustering=getattr(msm_cfg, "stable_clustering", False), + seed=self.config.random_seed, + ) + self.msm_monitor = build_monitor_from_config(msm_cfg) + def prepare(self): """Prepare the simulation system.""" self.engine.prepare( @@ -125,22 +204,14 @@ def validate_preflight(self) -> None: raise RuntimeError(f"Preflight checks failed:\n - {joined}") def _adaptive_model_kwargs(self) -> dict: - if hasattr(self.config.adaptive_model, "model_dump"): - kwargs = self.config.adaptive_model.model_dump() - else: - kwargs = self.config.adaptive_model.dict() + kwargs = self.config.adaptive_model.model_dump() kwargs["space_mode"] = self.config.space_mode return kwargs def restore_checkpoint(self, iteration: int): """Restore sampler state from a saved checkpoint and resume at the next iteration.""" restored_model = self.space_model - if restored_model is None and self.config.space_mode in [ - "tvae", - "tica", - "deep-tica", - "pca", - ]: + if restored_model is None and is_adaptive_space(self.config.space_mode): restored_model = AdaptiveSpaceModel(**self._adaptive_model_kwargs()) ( @@ -192,6 +263,7 @@ def resume_walkers(self) -> List[Any]: raise RuntimeError( f"Checkpoint history entry {latest_iteration} has no trajectories." ) + self._validate_sampling_trajectories(trajectories, context="resume") trajectory_topology = ( self.config.system.trajectory_topology_file or self.config.system.top_file @@ -205,6 +277,91 @@ def resume_walkers(self) -> List[Any]: list(spawn_indices), ) + + def generate_initial_walkers(self) -> List[Any]: + if not self.config.system.initial_trajectory: + return [self.engine.positions for _ in range(self.config.spawning.walker)] + + import logging + import numpy as np + import random + from pathlib import Path + import MDAnalysis as mda + from autosampler.spaces import FeatureExtractor + + traj_path = str(Path(self.config.system.initial_trajectory).resolve()) + logging.info(f"Initializing walkers from trajectory: {traj_path}") + + trajectory_topology = ( + self.config.system.trajectory_topology_file or self.config.system.top_file + ) + + u = mda.Universe(trajectory_topology, traj_path) + n_frames = len(u.trajectory) + n_walkers = self.config.spawning.walker + + if n_frames == 0: + raise ValueError(f"Initial trajectory {traj_path} contains 0 frames.") + + points = None + if hasattr(self, "_extract_physical_cvs") and self.config.system.project_file: + try: + points = self._extract_physical_cvs([traj_path]) + except Exception as e: + logging.warning(f"Failed to extract CVs from initial trajectory, falling back to random sampling: {e}") + + if points is not None and len(points) > n_walkers: + logging.info(f"Selecting {n_walkers} starting walkers from {n_frames} frames using spawning scheme...") + try: + # the spawner might require history for some things, but mostly just points. + # Since history is empty, pass it empty. + spawn_indices = self.spawner.sample(points, n_walkers, history={}) + except Exception as e: + logging.warning(f"Spawning scheme failed on initial trajectory: {e}. Falling back to uniform.") + spawn_indices = list(np.linspace(0, n_frames - 1, n_walkers, dtype=int)) + else: + if n_frames >= n_walkers: + logging.info(f"Using uniform sampling for {n_walkers} starting walkers from {n_frames} frames.") + spawn_indices = list(np.linspace(0, n_frames - 1, n_walkers, dtype=int)) + print("EXACT SPAWN INDICES:", spawn_indices) + else: + logging.info(f"Only {n_frames} frames available for {n_walkers} walkers; replicating randomly.") + spawn_indices = [random.choice(range(n_frames)) for _ in range(n_walkers)] + + feature_extractor = FeatureExtractor( + topology=trajectory_topology, + selection=self.config.system.feature_selection, + ) + walkers = feature_extractor.extract_positions_by_indices( + [traj_path], spawn_indices + ) + + if points is not None: + from autosampler.core import build_frame_records + frames = build_frame_records( + iteration=-1, + trajectories=[traj_path], + points=np.asarray(points), + walker_parents=["initial"], + expected_frames=n_frames, + ) + + next_walker_parents = [frames[idx]["key"] for idx in spawn_indices] + + self.history[-1] = { + "projection": points, + "spawning_scheme": "initial", + "trajectories": [traj_path], + "spawn_indices": list(spawn_indices), + "frames": frames, + "walker_parents": ["initial"], + "next_walker_parents": next_walker_parents, + } + self.walker_parents = next_walker_parents + logging.info(f"Injected {n_frames} frames from initial trajectory into permanent history (iteration -1).") + + return walkers + def _traj_suffix(self) -> str: if self.config.engine.md_engine == "amber": return amber_trajectory_suffix( @@ -222,7 +379,7 @@ def run_iteration(self, walkers: List[Any]): runner_start_time = time.time() engine_kwargs = { - k: v for k, v in self.config.engine.dict().items() if k != "md_engine" + k: v for k, v in self.config.engine.model_dump().items() if k != "md_engine" } prepare_kwargs = { "conf": Path(self.config.system.conf_file), @@ -243,6 +400,7 @@ def run_iteration(self, walkers: List[Any]): iteration=self.iteration, max_workers=self.config.spawning.max_workers, gpu_ids=self.config.engine.gpu_ids, + execution=getattr(self.config, "execution", None), ) runner_time = time.time() - runner_start_time @@ -277,6 +435,7 @@ def run_iteration(self, walkers: List[Any]): ) for idx in range(len(walkers)) ] + self._validate_trajectory_files(trajectories) trajectory_topology = ( self.config.system.trajectory_topology_file or self.config.system.top_file ) @@ -284,20 +443,10 @@ def run_iteration(self, walkers: List[Any]): topology=trajectory_topology, selection=self.config.system.feature_selection ) - if self.config.space_mode in ["tvae", "tica", "deep-tica", "pca"]: - # Feature Extraction - adaptive_feature_type = getattr( - self.config, "adaptive_feature_type", "distances" + if is_adaptive_space(self.config.space_mode): + features = self._extract_adaptive_features( + feature_extractor, trajectories ) - if adaptive_feature_type == "phi_psi": - logging.info("Extracting AIB9 phi/psi dihedral features...") - features = feature_extractor.extract_aib9_phi_psi(trajectories) - elif adaptive_feature_type == "fitted_coords": - logging.info("Extracting fitted Cartesian coordinate features...") - features = feature_extractor.extract_fitted_coords(trajectories) - else: - logging.info("Extracting pairwise distance features...") - features = feature_extractor.extract_pairwise_distances(trajectories) if self.config.save_features: np.savez_compressed( @@ -308,19 +457,29 @@ def run_iteration(self, walkers: List[Any]): if self.config.aggregate_memory: self.feature_memory.append(features) - # Train or update the Space Model - if self.space_model is None or ( - self.config.retrain_freq > 0 - and self.iteration % self.config.retrain_freq == 0 - ): + # Train or update the Space Model (cadence decided by RetrainController) + n_frames = self.config.spawning.step // self.config.spawning.stride + has_model = self.space_model is not None + current_cv_score = None + if has_model and self.config.retrain_policy == "vamp_adaptive": + current_cv_score = self._cv_vamp_score(features, n_frames, len(walkers)) + do_retrain = self.retrain_controller.should_retrain( + self.iteration, has_model, current_cv_score + ) + + if do_retrain: if self.space_model is None: logging.info(f"Training new {self.config.space_mode} model...") self.space_model = AdaptiveSpaceModel( **self._adaptive_model_kwargs() ) else: + reason = self.retrain_controller.last_reason or "scheduled" logging.info( - f"Retraining {self.config.space_mode} model (iteration {self.iteration})..." + "Retraining %s model (iteration %d): %s", + self.config.space_mode, + self.iteration, + reason, ) if self.config.aggregate_memory: @@ -346,36 +505,28 @@ def run_iteration(self, walkers: List[Any]): fit_features = features total_walkers = len(walkers) - n_frames = self.config.spawning.step // self.config.spawning.stride + self._maybe_select_features(fit_features, n_frames, total_walkers) self.space_model.fit( - fit_features, walker_length=n_frames, n_walkers=total_walkers + self._fs_apply(fit_features), + walker_length=n_frames, + n_walkers=total_walkers, ) self.scaler = self.space_model.scaler self.adaptive_space_version += 1 self._refresh_adaptive_history_projections() + self.retrain_controller.notify_retrained( + self._cv_vamp_score(features, n_frames, len(walkers)) + ) + else: + self.retrain_controller.notify_skipped() # Project onto latent space (project only current iteration features for history tracking) logging.info("Projecting features to latent space...") - points = self.space_model.project(features) + points = self.space_model.project(self._fs_apply(features)) else: # Physical fixed space if self.config.system.project_file: - import importlib.util - import sys - - project_path = Path(self.config.system.project_file) - spec = importlib.util.spec_from_file_location( - "custom_project", str(project_path) - ) - custom_project = importlib.util.module_from_spec(spec) - sys.modules["custom_project"] = custom_project - spec.loader.exec_module(custom_project) - - points = custom_project.extract_cvs( - trajectories=trajectories, - top_file=self.config.system.top_file, - conf_file=self.config.system.conf_file, - ) + points = self._extract_physical_cvs(trajectories) else: # Fallback to internal Rg-RMSD points = feature_extractor.extract_rg_rmsd( @@ -385,30 +536,16 @@ def run_iteration(self, walkers: List[Any]): # Always extract physical CVs for evaluation/plotting if project_file is provided if ( - self.config.space_mode in ["tvae", "tica", "deep-tica", "pca"] + is_adaptive_space(self.config.space_mode) and self.config.system.project_file ): try: - import importlib.util - import sys - - project_path = Path(self.config.system.project_file) - spec = importlib.util.spec_from_file_location( - "custom_project", str(project_path) - ) - custom_project = importlib.util.module_from_spec(spec) - sys.modules["custom_project"] = custom_project - spec.loader.exec_module(custom_project) - physical_points = custom_project.extract_cvs( - trajectories=trajectories, - top_file=self.config.system.top_file, - conf_file=self.config.system.conf_file, - ) + physical_points = self._extract_physical_cvs(trajectories) np.savez_compressed( self.outdir / f"iter_{self.iteration}" / "physical_cvs.npz", cvs=physical_points, ) - except Exception as e: + except (ImportError, AttributeError, RuntimeError, OSError, ValueError) as e: logging.warning(f"Failed to extract physical CVs for evaluation: {e}") np.savez_compressed( @@ -423,10 +560,23 @@ def run_iteration(self, walkers: List[Any]): walker_parents=self.walker_parents, expected_frames=expected_frames, ) + self._prune_unusable_history_trajectories() + # Hand the MSM-guided spawner the previous iteration's MSM + its clustering + # (consistent with each other; the spawner falls back to least-counts when + # either is None, e.g. iteration 0 or just after a resume). + if hasattr(self.spawner, "msm_result"): + self.spawner.msm_result = self.last_msm_result + self.spawner.cluster_model = getattr( + self.msm_estimator, "_cluster_model", None + ) spawn_indices = self.spawner.sample( points, self.config.spawning.walker, history=self.history ) sampling_trajectories = self._sampling_trajectories(trajectories) + self._validate_sampling_trajectories( + sampling_trajectories, + context=f"spawning iteration {self.iteration}", + ) sampling_frame_records = self._sampling_frame_records(current_frame_records) next_walker_parents = [ map_global_frame(sampling_frame_records, index)["key"] @@ -447,7 +597,7 @@ def run_iteration(self, walkers: List[Any]): "walker_parents": list(self.walker_parents), "next_walker_parents": next_walker_parents, } - if self.config.space_mode in ["tvae", "tica", "deep-tica", "pca"]: + if is_adaptive_space(self.config.space_mode): self.history[self.iteration]["features"] = features self.history[self.iteration]["space_version"] = self.adaptive_space_version @@ -476,7 +626,7 @@ def run_iteration(self, walkers: List[Any]): try: from autosampler.binning.spatial import RegularBinner - is_adaptive = self.config.space_mode in ["tvae", "tica", "deep-tica", "pca"] + is_adaptive = is_adaptive_space(self.config.space_mode) binner = RegularBinner( n_bins=self.config.n_bins, min_values=None if is_adaptive else self.config.min_values, @@ -506,6 +656,9 @@ def run_iteration(self, walkers: List[Any]): bin_occupancy_str = "N/A" logging.debug(f"Failed to compute bin occupancy: {e}") + # MSM estimation + MSM-based convergence (opt-in). + self._maybe_build_msm() + current_iteration = self.iteration - 1 self._append_iteration_log( iteration=current_iteration, @@ -520,42 +673,13 @@ def run_iteration(self, walkers: List[Any]): trajectories=trajectories, ) - # Informative Output Logging (Tabular UI) - PURPLE, CYAN, GREEN, YELLOW, RED, BOLD, END = ( - "\033[95m", - "\033[96m", - "\033[92m", - "\033[93m", - "\033[91m", - "\033[1m", - "\033[0m", - ) - width = 85 - - summary_str = ( - f" Iteration: {CYAN}{self.iteration - 1:<4}{END} | " - f"Runner: {CYAN}{runner_time:<6.2f}s{END} | " - f"Other: {CYAN}{other_time:<5.2f}s{END} | " - f"Occupancy: {CYAN}{bin_occupancy_str:<9}{END}" - ) - - # Calculate visual length safely by stripping ANSI codes to center properly - # Alternatively, we just construct a fixed width using raw strings then apply color. - raw_summary = f" Iteration: {self.iteration - 1:<4} | Runner: {runner_time:<6.2f}s | Other: {other_time:<5.2f}s | Occupancy: {bin_occupancy_str:<9}" - left_pad = (width - len(raw_summary)) // 2 - right_pad = width - len(raw_summary) - left_pad - - result = f"\t{RED}╔" + "═" * width + f"╗\n{END}" - result += ( - f"\t{RED}║{END}" - + " " * left_pad - + summary_str - + " " * right_pad - + f"{RED}║\n{END}" + # Informative per-iteration banner (presentation extracted to reporting). + self.reporter.print_summary( + iteration=self.iteration - 1, + runner_time=runner_time, + other_time=other_time, + occupancy=bin_occupancy_str, ) - result += f"\t{RED}╚" + "═" * width + f"╝{END}" - - print(result) return { "success": results, @@ -641,13 +765,13 @@ def _refresh_adaptive_history_projections(self) -> None: entry["projection"] = None continue entry["projection"] = self.space_model.project( - np.asarray(features, dtype=float) + self._fs_apply(np.asarray(features, dtype=float)) ) entry["space_version"] = self.adaptive_space_version def _restore_feature_memory_from_history(self) -> None: """Rebuild adaptive feature memory from checkpointed history when possible.""" - if self.config.space_mode not in ["tvae", "tica", "deep-tica", "pca"]: + if not is_adaptive_space(self.config.space_mode): return self.feature_memory = [] @@ -685,6 +809,12 @@ def _checkpoint_state(self) -> Dict[str, Any]: "convergence_stall_count": self.convergence_stall_count, "converged": self.converged, "convergence_reason": self.convergence_reason, + "msm_monitor": self.msm_monitor.state_dict() + if self.msm_monitor is not None + else None, + "feature_selection_indices": self.feature_selection_indices, + "selected_feature_type": self.selected_feature_type, + "retrain_controller": self.retrain_controller.state_dict(), } def _restore_sampler_state(self, state: Dict[str, Any] | None) -> None: @@ -702,6 +832,341 @@ def _restore_sampler_state(self, state: Dict[str, Any] | None) -> None: self.convergence_stall_count = int(state.get("convergence_stall_count", 0)) self.converged = bool(state.get("converged", False)) self.convergence_reason = state.get("convergence_reason") + if state.get("feature_selection_indices") is not None: + self.feature_selection_indices = list(state["feature_selection_indices"]) + if state.get("selected_feature_type") is not None: + self.selected_feature_type = state["selected_feature_type"] + self.retrain_controller.load_state_dict(state.get("retrain_controller", {})) + if self.msm_monitor is not None and state.get("msm_monitor"): + self.msm_monitor.load_state_dict(state["msm_monitor"]) + + @staticmethod + def _trajectory_file_problems(trajectories: List[str]) -> list[str]: + bad: list[str] = [] + for path in trajectories: + p = Path(path) + if not p.is_file(): + bad.append(f"missing: {path}") + elif p.stat().st_size == 0: + bad.append(f"empty: {path}") + return bad + + @staticmethod + def _validate_trajectory_files(trajectories: List[str]) -> None: + """Ensure each expected trajectory exists and is non-empty before reading. + + A walker can report success yet leave a missing or truncated file (disk + full, killed writer); catching it here gives a clear error instead of an + opaque downstream parse failure. + """ + bad = AutoSamplerCore._trajectory_file_problems(trajectories) + if bad: + joined = "\n - ".join(bad) + raise RuntimeError( + "Trajectory files are not usable for CV extraction:\n - " + joined + ) + + @staticmethod + def _validate_sampling_trajectories( + trajectories: List[str], context: str = "sampling" + ) -> None: + """Ensure cumulative trajectories are still available before spawning. + + Current iteration outputs are validated before CV extraction. Spawning can + also sample frames from older history, especially after resume, so check + the complete sampling pool before handing paths to MDAnalysis. + """ + try: + AutoSamplerCore._validate_trajectory_files(trajectories) + except RuntimeError as exc: + raise RuntimeError( + f"Cannot extract walker start coordinates during {context}; " + "one or more sampled-history trajectory files are missing or empty. " + "Restore the listed files, remove the incomplete run directory, or " + "resume from a checkpoint whose trajectory files are present.\n" + f"{exc}" + ) from exc + + def _prune_unusable_history_trajectories(self) -> None: + """Drop old history entries whose trajectory files can no longer be read.""" + dropped: list[tuple[int, list[str]]] = [] + for iteration in sorted(list(self.history)): + entry = self.history[iteration] + if not isinstance(entry, dict) or entry.get("projection") is None: + continue + stored = entry.get("trajectories") + trajectories = ( + [str(path) for path in stored] + if stored + else self._infer_iteration_trajectories(iteration, entry["projection"]) + ) + problems = self._trajectory_file_problems(trajectories) + if problems: + dropped.append((iteration, problems)) + del self.history[iteration] + + for iteration, problems in dropped: + logging.warning( + "Dropping iteration %s from sampling history because trajectory " + "files are missing or empty: %s", + iteration, + "; ".join(problems[:5]), + ) + + def _fs_apply(self, features: np.ndarray) -> np.ndarray: + """Restrict features to the VAMP-selected columns (no-op if disabled).""" + if self.feature_selection_indices is None: + return features + return np.asarray(features)[:, self.feature_selection_indices] + + def _extract_feature_type( + self, feature_extractor: FeatureExtractor, trajectories: List[str], ftype: str + ) -> np.ndarray: + if ftype == "phi_psi": + return feature_extractor.extract_aib9_phi_psi(trajectories) + if ftype == "fitted_coords": + return feature_extractor.extract_fitted_coords(trajectories) + return feature_extractor.extract_pairwise_distances(trajectories) + + def _extract_adaptive_features( + self, feature_extractor: FeatureExtractor, trajectories: List[str] + ) -> np.ndarray: + """Extract input features, optionally ranking candidate feature *types* by VAMP-2.""" + fs_cfg = getattr(self.config, "feature_selection", None) + candidates = list(getattr(fs_cfg, "candidate_feature_types", []) or []) + default_type = getattr(self.config, "adaptive_feature_type", "distances") + + if not (fs_cfg and fs_cfg.enabled and candidates): + logging.info("Extracting %s features...", default_type) + return self._extract_feature_type( + feature_extractor, trajectories, self.selected_feature_type or default_type + ) + + due = self.selected_feature_type is None or ( + fs_cfg.cadence > 0 and self.iteration % fs_cfg.cadence == 0 + ) + if not due: + return self._extract_feature_type( + feature_extractor, trajectories, self.selected_feature_type + ) + + # Extract every candidate type and rank them by VAMP-2. + from autosampler.spaces.feature_selection import rank_candidates + + n_frames = self.config.spawning.step // self.config.spawning.stride + extracted: Dict[str, np.ndarray] = {} + for ftype in candidates: + try: + extracted[ftype] = self._extract_feature_type( + feature_extractor, trajectories, ftype + ) + except Exception as exc: # noqa: BLE001 - skip system-incompatible types + logging.warning("Skipping feature type %r: %s", ftype, exc) + if not extracted: + return self._extract_feature_type(feature_extractor, trajectories, default_type) + + candidate_trajs = { + ftype: [ + feats[i * n_frames : (i + 1) * n_frames] + for i in range(max(1, len(feats) // max(1, n_frames))) + ] + for ftype, feats in extracted.items() + } + ranked = rank_candidates(candidate_trajs, fs_cfg.lagtime) + best = ranked[0][0] + if best != self.selected_feature_type: + # Column indices are tied to a feature type; reset on a type change. + self.feature_selection_indices = None + logging.info( + "VAMP-2 feature-type selection -> %s (scores: %s)", + best, + ", ".join(f"{n}={s:.3f}" for n, s in ranked), + ) + self.selected_feature_type = best + return extracted[best] + + def _cv_vamp_score( + self, features: np.ndarray, walker_length: int, n_walkers: int + ) -> float | None: + """VAMP-2 score of the current CV's latent projection of ``features``.""" + if self.space_model is None: + return None + try: + latent = np.asarray(self.space_model.project(self._fs_apply(features))) + except Exception: # noqa: BLE001 - scoring is best-effort + return None + if latent.ndim == 1: + latent = latent.reshape(-1, 1) + from autosampler.spaces.feature_selection import vamp2_score + + lag = self.config.adaptive_model.lagtime + trajs = [ + latent[i * walker_length : (i + 1) * walker_length] + for i in range(max(1, n_walkers)) + ] + trajs = [t for t in trajs if len(t) > lag] + if not trajs: + return None + try: + return vamp2_score(trajs, lag) + except (ValueError, np.linalg.LinAlgError): + return None + + def _maybe_select_features( + self, fit_features: np.ndarray, walker_length: int, n_walkers: int + ) -> None: + """(Re)select input-feature columns by VAMP-2 when due (opt-in).""" + if self.feature_selector is None: + return + cadence = self.config.feature_selection.cadence + due = self.feature_selection_indices is None or ( + cadence > 0 and self.iteration % cadence == 0 + ) + if not due: + return + + trajs = [ + np.asarray(fit_features[i * walker_length : (i + 1) * walker_length]) + for i in range(max(1, n_walkers)) + ] + trajs = [t for t in trajs if len(t) > self.feature_selector.lagtime] + if not trajs: + return + try: + selection = self.feature_selector.select(trajs) + except (ValueError, np.linalg.LinAlgError) as exc: + logging.warning("Feature selection skipped: %s", exc) + return + self.feature_selection_indices = selection.columns + self.last_feature_selection = selection + logging.info( + "VAMP-2 feature selection: kept %d/%d features (score %.3f).", + len(selection.columns), + np.asarray(fit_features).shape[1], + selection.score, + ) + + def _extract_physical_cvs(self, trajectories: List[str]) -> np.ndarray: + """Load the user project file and extract physical CVs for ``trajectories``. + + Centralises the ``project_file`` import + ``extract_cvs`` call that was + previously duplicated for both the fixed-space projection and the + evaluation-only physical CVs. + """ + project_path = Path(self.config.system.project_file) + spec = importlib.util.spec_from_file_location( + "custom_project", str(project_path) + ) + custom_project = importlib.util.module_from_spec(spec) + sys.modules["custom_project"] = custom_project + spec.loader.exec_module(custom_project) + return custom_project.extract_cvs( + trajectories=trajectories, + top_file=self.config.system.top_file, + conf_file=self.config.system.conf_file, + ) + + def _collect_msm_trajectories(self) -> List[Any]: + """Split cumulative history projections into continuous per-walker trajectories. + + Each short walker is one continuous trajectory; transition counts are + pooled across all of them by the estimator. Single-frame segments are + dropped because they carry no transitions. + """ + frames_per_walker = self.config.spawning.step // self.config.spawning.stride + trajs: List[Any] = [] + for iteration in sorted(self.history): + entry = self.history[iteration] + if not isinstance(entry, dict): + continue + projection = entry.get("projection") + if projection is None: + continue + projection = np.asarray(projection, dtype=float) + if projection.ndim == 1: + projection = projection.reshape(-1, 1) + if frames_per_walker <= 0: + if len(projection) > 1: + trajs.append(projection) + continue + n_walkers = int(np.ceil(len(projection) / frames_per_walker)) + for walker in range(n_walkers): + segment = projection[ + walker * frames_per_walker : (walker + 1) * frames_per_walker + ] + if len(segment) > 1: + trajs.append(segment) + return trajs + + def _maybe_build_msm(self) -> None: + """Estimate the MSM for the just-completed iteration and update convergence.""" + if self.msm_estimator is None or self.msm_monitor is None: + return + + msm_cfg = self.config.msm + current_iteration = self.iteration - 1 + if msm_cfg.cadence > 0 and current_iteration % msm_cfg.cadence != 0: + return + + trajs = self._collect_msm_trajectories() + total_frames = sum(len(t) for t in trajs) + if total_frames < msm_cfg.min_frames: + logging.info( + "MSM skipped at iteration %d: %d cumulative frames < min_frames %d.", + current_iteration, + total_frames, + msm_cfg.min_frames, + ) + return + + try: + result = self.msm_estimator.fit(trajs, iteration=current_iteration) + except Exception as exc: # noqa: BLE001 - MSM is diagnostic, never fatal + logging.warning( + "MSM estimation failed at iteration %d: %s", current_iteration, exc + ) + return + + self.last_msm_result = result + logging.info("Iteration %d %s", current_iteration, result.summary()) + self._save_msm_result(current_iteration, result) + + converged = self.msm_monitor.update(result) + logging.info("MSM convergence %s", self.msm_monitor.status_line()) + if converged and not self.converged: + self.converged = True + self.convergence_reason = self.msm_monitor.reason + + def _save_msm_result(self, iteration: int, result: Any) -> None: + try: + vamp2 = np.nan if result.vamp2_score is None else result.vamp2_score + arrays = { + "lagtime": np.asarray(result.lagtime), + "timescales": np.asarray(result.timescales, dtype=float), + "stationary_distribution": np.asarray( + result.stationary_distribution, dtype=float + ), + "transition_matrix": np.asarray( + result.transition_matrix, dtype=float + ), + "cluster_centers": np.asarray(result.cluster_centers, dtype=float), + "vamp2_score": np.asarray([vamp2], dtype=float), + } + if getattr(result, "metastable_populations", None) is not None: + arrays["metastable_populations"] = np.asarray( + result.metastable_populations, dtype=float + ) + if getattr(result, "count_matrix", None) is not None: + arrays["count_matrix"] = np.asarray(result.count_matrix, dtype=float) + if getattr(result, "eigenvectors", None) is not None: + arrays["eigenvectors"] = np.asarray(result.eigenvectors, dtype=float) + its = getattr(result, "its", None) + if its is not None: + arrays["its_lagtimes"] = np.asarray(its.lagtimes, dtype=float) + arrays["its_timescales"] = np.asarray(its.timescales, dtype=float) + np.savez_compressed(self.outdir / f"iter_{iteration}" / "msm.npz", **arrays) + except Exception as exc: # noqa: BLE001 + logging.debug("Failed to save msm.npz for iteration %d: %s", iteration, exc) def _update_resolution_and_convergence(self, occupied_bins: int | None) -> None: if occupied_bins is None: diff --git a/autosampler/engines/amber.py b/autosampler/engines/amber.py index e05480c..0f67ac0 100644 --- a/autosampler/engines/amber.py +++ b/autosampler/engines/amber.py @@ -26,7 +26,7 @@ import numpy as np -from .base import MDEngine +from .base import MDEngine, md_subprocess_timeout def resolve_amber_trajectory_format( @@ -319,6 +319,7 @@ def run_production( capture_output=True, text=True, check=True, + timeout=md_subprocess_timeout(), ) except subprocess.CalledProcessError as exc: logging.error( @@ -328,6 +329,13 @@ def run_production( exc.stderr, ) return False + except subprocess.TimeoutExpired: + logging.error( + "AmberEngine run %d timed out after %ss.", + run_index, + md_subprocess_timeout(), + ) + return False except FileNotFoundError: logging.error( "AmberEngine: executable %r not found on PATH.", self.amber_executable diff --git a/autosampler/engines/base.py b/autosampler/engines/base.py index 6e7ecbf..a074b0b 100644 --- a/autosampler/engines/base.py +++ b/autosampler/engines/base.py @@ -1,18 +1,35 @@ +import os from abc import ABC, abstractmethod from pathlib import Path -import numpy as np -from typing import Optional + + +def md_subprocess_timeout() -> float | None: + """Timeout (seconds) for external MD subprocesses, or None for no limit. + + Configured via the ``AUTOSAMPLER_MD_TIMEOUT`` environment variable so it + propagates cleanly to walker worker processes without threading through + engine constructors. Guards against hung ``gmx``/``pmemd`` invocations. + """ + raw = os.environ.get("AUTOSAMPLER_MD_TIMEOUT") + if not raw: + return None + try: + value = float(raw) + except ValueError: + return None + return value if value > 0 else None + class MDEngine(ABC): """Abstract Strategy interface for molecular dynamics execution.""" - + @abstractmethod - def prepare(self, conf: Path, top: Path, system_file: Optional[Path] = None) -> None: + def prepare(self, conf: Path, top: Path, system_file: Path | None = None) -> None: """Prepare the MD environment, e.g., setup system, topology, forces.""" pass @abstractmethod - def run_production(self, run_index: int, start_coords: Path, steps: int, + def run_production(self, run_index: int, start_coords: Path, steps: int, traj_out: Path, stride: int, device_index: int) -> bool: """Execute a production run from start_coords for a given number of steps.""" pass diff --git a/autosampler/engines/gromacs.py b/autosampler/engines/gromacs.py index b679db3..41bc9b4 100644 --- a/autosampler/engines/gromacs.py +++ b/autosampler/engines/gromacs.py @@ -32,7 +32,7 @@ import numpy as np -from .base import MDEngine +from .base import MDEngine, md_subprocess_timeout class GromacsEngine(MDEngine): @@ -243,6 +243,7 @@ def run_production( capture_output=True, text=True, check=True, + timeout=md_subprocess_timeout(), ) except subprocess.CalledProcessError as exc: logging.error( @@ -252,6 +253,13 @@ def run_production( exc.stderr, ) return False + except subprocess.TimeoutExpired: + logging.error( + "GromacsEngine grompp run %d timed out after %ss.", + run_index, + md_subprocess_timeout(), + ) + return False except FileNotFoundError: logging.error( "GromacsEngine: executable %r not found on PATH.", @@ -283,6 +291,7 @@ def run_production( capture_output=True, text=True, check=True, + timeout=md_subprocess_timeout(), ) except subprocess.CalledProcessError as exc: logging.error( @@ -292,6 +301,13 @@ def run_production( exc.stderr, ) return False + except subprocess.TimeoutExpired: + logging.error( + "GromacsEngine mdrun run %d timed out after %ss.", + run_index, + md_subprocess_timeout(), + ) + return False except FileNotFoundError: logging.error( "GromacsEngine: executable %r not found on PATH.", diff --git a/autosampler/engines/openmm.py b/autosampler/engines/openmm.py index 741fe5a..a34dcbb 100644 --- a/autosampler/engines/openmm.py +++ b/autosampler/engines/openmm.py @@ -38,6 +38,30 @@ def __init__( self.simulation = None self.positions = None + @staticmethod + def _available_platforms() -> list[str]: + return [ + Platform.getPlatform(i).getName() + for i in range(Platform.getNumPlatforms()) + ] + + @classmethod + def _get_platform(cls, platform_name: str): + try: + return Platform.getPlatformByName(platform_name) + except Exception as exc: + available = ", ".join(cls._available_platforms()) or "none" + hint = ( + "Install the CUDA-enabled OpenMM package, for example " + "`conda install -c conda-forge openmm cuda-version=12` or " + "`python -m pip install 'openmm[cuda12]'`, after confirming " + "that the NVIDIA driver is installed. For CPU-only runs, set " + "`engine.platform_name: CPU` in the AutoSampler config." + ) + import logging + logging.warning(f"OpenMM platform {platform_name} validation failed (likely because you are on a login node). Assuming compute nodes will have it. Error: {exc}") + return None + def prepare( self, conf: Path, top: Path, system_file: Optional[Path] = None ) -> None: @@ -91,7 +115,7 @@ def prepare( self.barostatInterval = 25 self.equilibrationSteps = 5000 - self.platform = Platform.getPlatformByName(self.platform_name) + self.platform = self._get_platform(self.platform_name) self.topology = self.top.topology self.positions = self.gro.positions @@ -160,7 +184,7 @@ def _create_simulation(self, device_index: int): if "CUDA_ERROR_NO_DEVICE" not in str(e): raise print("CUDA device unavailable; falling back to CPU platform.") - self.platform = Platform.getPlatformByName("CPU") + self.platform = self._get_platform("CPU") self.platform_name = "CPU" self.simulation = Simulation( self.topology, self.system, self.integrator, self.platform diff --git a/autosampler/execution/__init__.py b/autosampler/execution/__init__.py new file mode 100644 index 0000000..8efa5de --- /dev/null +++ b/autosampler/execution/__init__.py @@ -0,0 +1,47 @@ +"""Pluggable execution backends: local multiprocessing and HPC schedulers.""" + +# Import backend modules for their factory-registration side effects. +from . import ( + local, # noqa: F401,E402 + pbs, # noqa: F401,E402 + slurm, # noqa: F401,E402 +) +from .base import ( + ExecutionBackend, + ExecutionBackendFactory, + WalkerTask, + build_walker_tasks, + run_walker_task, +) + +__all__ = [ + "ExecutionBackend", + "ExecutionBackendFactory", + "WalkerTask", + "build_walker_tasks", + "run_walker_task", + "make_backend", +] + + +def make_backend(execution_config, *, gpu_ids=None, max_workers: int = 8): + """Construct the configured execution backend. + + ``execution_config`` is an ``ExecutionConfig`` (or ``None`` for local + defaults). ``gpu_ids`` / ``max_workers`` come from the engine/spawning + config and apply to the local backend. + """ + if execution_config is None: + return ExecutionBackendFactory.get( + "local", gpu_ids=gpu_ids, max_workers=max_workers + ) + + backend = getattr(execution_config, "backend", "local") + if backend == "local": + return ExecutionBackendFactory.get( + "local", gpu_ids=gpu_ids, max_workers=max_workers + ) + + cfg = execution_config.model_dump() + cfg.pop("backend", None) + return ExecutionBackendFactory.get(backend, **cfg) diff --git a/autosampler/execution/base.py b/autosampler/execution/base.py new file mode 100644 index 0000000..c5d52ef --- /dev/null +++ b/autosampler/execution/base.py @@ -0,0 +1,134 @@ +"""Pluggable execution backends for dispatching walker MD jobs. + +A backend turns a list of :class:`WalkerTask` (one short MD run each) into a +list of per-walker success flags. Implementations: + +- ``local`` — subprocesses across CPU/GPU slots on one node (workstation). +- ``slurm`` / ``pbs`` — one scheduler array job per iteration (HPC clusters). + +All backends share the same ``execute(tasks) -> list[bool]`` contract so the +orchestrator is agnostic to where walkers actually run. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from autosampler.engines.amber import amber_trajectory_suffix + + +def _traj_suffix(engine_name: str, engine_kwargs: dict) -> str: + if engine_name == "amber": + return amber_trajectory_suffix( + engine_kwargs.get("amber_trajectory_format", "auto"), + engine_kwargs.get("amber_executable", "pmemd"), + ) + return "xtc" + + +@dataclass +class WalkerTask: + """A single short MD run: everything a worker needs, sans device binding. + + ``device_index`` is injected by the backend at dispatch time (the local + backend assigns GPU slots dynamically; schedulers bind GPUs per job). + """ + + index: int + engine_name: str + engine_kwargs: dict + prepare_kwargs: dict + steps: int + stride: int + traj_out: str + start_coords: Any = None + device_index: int = 0 + + def run_kwargs(self) -> dict: + return { + "run_index": self.index, + "start_coords": self.start_coords, + "steps": self.steps, + "traj_out": Path(self.traj_out), + "stride": self.stride, + "device_index": self.device_index, + } + + +def build_walker_tasks( + *, + engine_name: str, + engine_kwargs: dict, + prepare_kwargs: dict, + walkers: list, + steps: int, + stride: int, + outdir: Path, + iteration: int, +) -> list[WalkerTask]: + """Construct one :class:`WalkerTask` per walker with deterministic file names.""" + suffix = _traj_suffix(engine_name, engine_kwargs) + tasks: list[WalkerTask] = [] + for idx, coords in enumerate(walkers): + traj_out = outdir / f"iteration_{iteration}_{idx}.{suffix}" + tasks.append( + WalkerTask( + index=idx, + engine_name=engine_name, + engine_kwargs=engine_kwargs, + prepare_kwargs=prepare_kwargs, + steps=steps, + stride=stride, + traj_out=str(traj_out), + start_coords=coords, + ) + ) + return tasks + + +def run_walker_task(task: WalkerTask) -> bool: + """Instantiate the engine in-process and run one production walker.""" + import warnings + + warnings.filterwarnings( + "ignore", message="Non-optimal GB parameters detected for GB model HCT" + ) + warnings.filterwarnings("ignore", message="Reload offsets from trajectory") + + from autosampler.engines.base import EngineFactory + + engine = EngineFactory.get(task.engine_name, **task.engine_kwargs) + engine.prepare(**task.prepare_kwargs) + return bool(engine.run_production(**task.run_kwargs())) + + +class ExecutionBackend(ABC): + """Strategy interface for executing a batch of walker tasks.""" + + @abstractmethod + def execute(self, tasks: list[WalkerTask]) -> list[bool]: + """Run all ``tasks`` and return success flags ordered by ``task.index``.""" + + +class ExecutionBackendFactory: + _backends: dict[str, type[ExecutionBackend]] = {} + + @classmethod + def register(cls, name: str, backend_cls: type[ExecutionBackend]) -> None: + cls._backends[name] = backend_cls + + @classmethod + def get(cls, name: str, **kwargs) -> ExecutionBackend: + if name not in cls._backends: + raise ValueError( + f"Unknown execution backend: {name!r}. " + f"Available: {sorted(cls._backends)}" + ) + return cls._backends[name](**kwargs) + + @classmethod + def available(cls) -> list[str]: + return sorted(cls._backends) diff --git a/autosampler/execution/local.py b/autosampler/execution/local.py new file mode 100644 index 0000000..e39b43e --- /dev/null +++ b/autosampler/execution/local.py @@ -0,0 +1,130 @@ +"""Local multi-process execution backend (multi-GPU workstation / single node). + +Runs walkers as subprocesses across CPU worker slots or GPU device slots, +assigning GPU device indices dynamically as workers free up. This preserves the +original ``run_iteration_parallel`` behaviour behind the ExecutionBackend API. +""" + +from __future__ import annotations + +import os +from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, wait + +from .base import ExecutionBackend, ExecutionBackendFactory, WalkerTask, run_walker_task + + +def _detect_gpu_ids() -> list[int]: + visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES") + if visible_devices: + try: + return [int(device.strip()) for device in visible_devices.split(",")] + except ValueError: + pass + try: + import torch + + num_gpus = torch.cuda.device_count() + except ImportError: + num_gpus = 0 + if num_gpus <= 0: + return [0] + return list(range(num_gpus)) + + +def _uses_gpu_slots(engine_name: str, engine_kwargs: dict) -> bool: + if engine_name == "amber": + return "cuda" in str(engine_kwargs.get("amber_executable", "")).lower() + if engine_name == "gromacs": + return any( + str(engine_kwargs.get(key, "")).lower() == "gpu" + for key in ( + "gromacs_mdrun_nb", + "gromacs_mdrun_pme", + "gromacs_mdrun_update", + "gromacs_mdrun_bonded", + ) + ) + if engine_name == "openmm": + platform = str(engine_kwargs.get("platform_name", "CUDA")).lower() + return platform not in {"cpu", "reference"} + return True + + +def _execution_slots( + engine_name: str, + engine_kwargs: dict, + gpu_ids: list[int] | None, + max_workers: int, + n_walkers: int, +) -> list[int]: + if max_workers <= 0: + raise ValueError("max_workers must be greater than 0") + if _uses_gpu_slots(engine_name, engine_kwargs): + reserved = list(gpu_ids) if gpu_ids is not None else _detect_gpu_ids() + if not reserved: + reserved = [0] + worker_count = min(max_workers, len(reserved), n_walkers) + if worker_count <= 0: + raise ValueError("max_workers must be greater than 0") + return reserved[:worker_count] + return [0 for _ in range(min(max_workers, n_walkers))] + + +def _run_one(task: WalkerTask, device_index: int) -> bool: + task.device_index = device_index + return run_walker_task(task) + + +class LocalProcessBackend(ExecutionBackend): + def __init__( + self, gpu_ids: list[int] | None = None, max_workers: int = 8, **_ + ): + self.gpu_ids = gpu_ids + self.max_workers = max_workers + + def execute(self, tasks: list[WalkerTask]) -> list[bool]: + import multiprocessing as mp + + if not tasks: + return [] + + engine_name = tasks[0].engine_name + engine_kwargs = tasks[0].engine_kwargs + slots = _execution_slots( + engine_name, engine_kwargs, self.gpu_ids, self.max_workers, len(tasks) + ) + + ctx = mp.get_context("spawn") + results = [False] * len(tasks) + task_iter = iter(tasks) + + def submit(executor, device_index: int): + try: + task = next(task_iter) + except StopIteration: + return None + future = executor.submit(_run_one, task, device_index) + return future, task.index, device_index + + with ProcessPoolExecutor(max_workers=len(slots), mp_context=ctx) as executor: + active: dict = {} + for device_index in slots: + submitted = submit(executor, device_index) + if submitted is not None: + future, idx, dev = submitted + active[future] = (idx, dev) + + while active: + done, _ = wait(active, return_when=FIRST_COMPLETED) + for future in done: + idx, freed_device = active.pop(future) + results[idx] = future.result() + submitted = submit(executor, freed_device) + if submitted is not None: + next_future, next_idx, dev = submitted + active[next_future] = (next_idx, dev) + + return results + + +ExecutionBackendFactory.register("local", LocalProcessBackend) diff --git a/autosampler/execution/pbs.py b/autosampler/execution/pbs.py new file mode 100644 index 0000000..8926008 --- /dev/null +++ b/autosampler/execution/pbs.py @@ -0,0 +1,60 @@ +"""PBS / Torque (PBS Pro) array-job execution backend.""" + +from __future__ import annotations + +from pathlib import Path + +from .base import ExecutionBackendFactory +from .scheduler import SchedulerBackend + + +class PBSBackend(SchedulerBackend): + array_index_var = "PBS_ARRAY_INDEX" + + def _directives(self, n_tasks: int, logdir: Path) -> list[str]: + select = f"select=1:ncpus={self.cpus_per_task}" + if self.gpus_per_task > 0: + select += f":ngpus={self.gpus_per_task}" + if self.memory: + select += f":mem={self.memory}" + d = [ + f"#PBS -N {self.job_name}", + f"#PBS -J 0-{n_tasks - 1}", + f"#PBS -l {select}", + f"#PBS -l walltime={self.walltime}", + f"#PBS -o {logdir}/", + f"#PBS -e {logdir}/", + ] + if self.partition: # PBS "queue" + d.append(f"#PBS -q {self.partition}") + if self.account: + d.append(f"#PBS -A {self.account}") + d += [line for line in self.extra_directives] + return d + + def _submit_command(self, script_path: Path) -> list[str]: + return ["qsub", str(script_path)] + + def _parse_job_id(self, stdout: str) -> str: + # qsub prints the job id, e.g. `1234[].pbsserver`. + return stdout.strip().splitlines()[-1].strip() if stdout.strip() else "" + + def _poll_command(self, job_id: str) -> list[str]: + return ["qstat", "-t", job_id] + + def _job_active(self, job_id: str, poll_stdout: str, returncode: int) -> bool: + # qstat returns non-zero once the job is fully gone from the system. + if returncode != 0: + return False + # Any array subjob not in state C (completed) means still active. + active = False + for line in poll_stdout.splitlines(): + parts = line.split() + if len(parts) >= 5 and parts[0].split(".")[0].split("[")[0].isdigit(): + state = parts[-2] + if state not in {"C", "F", "X"}: + active = True + return active + + +ExecutionBackendFactory.register("pbs", PBSBackend) diff --git a/autosampler/execution/run_task.py b/autosampler/execution/run_task.py new file mode 100644 index 0000000..2852ce1 --- /dev/null +++ b/autosampler/execution/run_task.py @@ -0,0 +1,49 @@ +"""Entry point executed by a single scheduler array task. + +Usage:: + + python -m autosampler.execution.run_task + +Loads a pickled :class:`~autosampler.execution.base.WalkerTask`, runs it, and +writes a JSON result marker. The marker is the source of truth for completion +and success, so it is written even when the run fails — letting the submitting +process distinguish "failed" from "never ran" (e.g. node crash). +""" + +from __future__ import annotations + +import json +import os +import pickle +import sys +import traceback + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if len(argv) != 2: + print("usage: run_task ", file=sys.stderr) + return 2 + task_path, result_path = argv + + result: dict = {"success": False, "error": None} + try: + with open(task_path, "rb") as handle: + task = pickle.load(handle) + result["index"] = getattr(task, "index", None) + from .base import run_walker_task + + result["success"] = bool(run_walker_task(task)) + except Exception as exc: # noqa: BLE001 - report any failure via the marker + result["error"] = f"{type(exc).__name__}: {exc}" + result["traceback"] = traceback.format_exc() + + tmp = f"{result_path}.tmp" + with open(tmp, "w") as handle: + json.dump(result, handle) + os.replace(tmp, result_path) # atomic publish + return 0 if result["success"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/autosampler/execution/scheduler.py b/autosampler/execution/scheduler.py new file mode 100644 index 0000000..6fbe9cd --- /dev/null +++ b/autosampler/execution/scheduler.py @@ -0,0 +1,203 @@ +"""Base class for HPC scheduler execution backends (SLURM, PBS). + +Each iteration's walkers are dispatched as one *array job*. The flow is: + +1. Pickle each pending :class:`WalkerTask` and write a manifest line + `` `` per task. +2. Render a scheduler script whose array elements read their manifest line and + invoke :mod:`autosampler.execution.run_task`. +3. Submit, then poll until every result marker appears (or the job leaves the + queue). Missing/failed markers are resubmitted up to ``max_retries`` times. + +Completion is driven by **filesystem result markers**, not scheduler accounting, +which makes the logic portable and unit-testable: the only external seam is +``command_runner`` (a callable wrapping ``subprocess.run``), which tests replace +with a fake scheduler that runs tasks synchronously. +""" + +from __future__ import annotations + +import json +import pickle +import subprocess +import time +from abc import abstractmethod +from collections.abc import Callable +from pathlib import Path + +from .base import ExecutionBackend, WalkerTask + +CommandRunner = Callable[[list[str], float], "subprocess.CompletedProcess[str]"] + + +def _default_command_runner(cmd: list[str], timeout: float): + return subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, check=False + ) + + +class SchedulerBackend(ExecutionBackend): + """Shared submit/poll/retry machinery for array-job schedulers.""" + + def __init__( + self, + *, + partition: str | None = None, + account: str | None = None, + walltime: str = "01:00:00", + cpus_per_task: int = 1, + gpus_per_task: int = 0, + memory: str | None = None, + max_retries: int = 1, + poll_interval: float = 30.0, + submit_timeout: float = 60.0, + module_loads: list[str] | None = None, + extra_directives: list[str] | None = None, + job_name: str = "autosampler", + command_runner: CommandRunner | None = None, + sleep_fn: Callable[[float], None] | None = None, + python_executable: str | None = None, + **_, + ): + self.partition = partition + self.account = account + self.walltime = walltime + self.cpus_per_task = cpus_per_task + self.gpus_per_task = gpus_per_task + self.memory = memory + self.max_retries = max_retries + self.poll_interval = poll_interval + self.submit_timeout = submit_timeout + self.module_loads = list(module_loads or []) + self.extra_directives = list(extra_directives or []) + self.job_name = job_name + self._run_command = command_runner or _default_command_runner + self._sleep = sleep_fn or time.sleep + import sys + + self.python_executable = python_executable or sys.executable + + # ── scheduler-specific hooks ──────────────────────────────────────────── + @property + @abstractmethod + def array_index_var(self) -> str: + """Env var holding the array index (e.g. ``SLURM_ARRAY_TASK_ID``).""" + + @abstractmethod + def _directives(self, n_tasks: int, logdir: Path) -> list[str]: + """Scheduler directive lines (``#SBATCH`` / ``#PBS``).""" + + @abstractmethod + def _submit_command(self, script_path: Path) -> list[str]: + ... + + @abstractmethod + def _parse_job_id(self, stdout: str) -> str: + ... + + @abstractmethod + def _poll_command(self, job_id: str) -> list[str]: + ... + + @abstractmethod + def _job_active(self, job_id: str, poll_stdout: str, returncode: int) -> bool: + """True while any array element is still queued/running.""" + + # ── core flow ─────────────────────────────────────────────────────────── + def execute(self, tasks: list[WalkerTask]) -> list[bool]: + if not tasks: + return [] + + iter_dir = Path(tasks[0].traj_out).parent + jobdir = iter_dir / "_jobs" + jobdir.mkdir(parents=True, exist_ok=True) + + task_files: dict[int, Path] = {} + result_files: dict[int, Path] = {} + for task in tasks: + tf = jobdir / f"task_{task.index}.pkl" + with open(tf, "wb") as handle: + pickle.dump(task, handle) + task_files[task.index] = tf + result_files[task.index] = jobdir / f"result_{task.index}.json" + + success: dict[int, bool] = {} + for attempt in range(self.max_retries + 1): + pending = [t.index for t in tasks if not success.get(t.index, False)] + if not pending: + break + self._dispatch_attempt(attempt, pending, task_files, result_files, jobdir) + for idx in pending: + success[idx] = self._read_success(result_files[idx]) + + return [success.get(task.index, False) for task in tasks] + + def _dispatch_attempt( + self, + attempt: int, + pending: list[int], + task_files: dict[int, Path], + result_files: dict[int, Path], + jobdir: Path, + ) -> None: + # Clear stale markers for the indices we are about to (re)run. + for idx in pending: + result_files[idx].unlink(missing_ok=True) + + manifest = jobdir / f"manifest_attempt{attempt}.txt" + manifest.write_text( + "\n".join(f"{task_files[i]} {result_files[i]}" for i in pending) + "\n" + ) + logdir = jobdir / f"logs_attempt{attempt}" + logdir.mkdir(exist_ok=True) + script = self._render_script(len(pending), manifest, logdir) + script_path = jobdir / f"submit_attempt{attempt}.sh" + script_path.write_text(script) + + proc = self._run_command( + self._submit_command(script_path), self.submit_timeout + ) + if proc.returncode != 0: + raise RuntimeError( + f"{type(self).__name__} submission failed (exit {proc.returncode}): " + f"{proc.stderr.strip()}" + ) + job_id = self._parse_job_id(proc.stdout) + self._wait_for_completion(job_id, [result_files[i] for i in pending]) + + def _render_script(self, n_tasks: int, manifest: Path, logdir: Path) -> str: + lines = ["#!/bin/bash"] + lines += self._directives(n_tasks, logdir) + lines.append("set -euo pipefail") + lines += self.module_loads + lines.append(f'MANIFEST="{manifest}"') + lines.append(f'LINE=$(sed -n "$((${self.array_index_var}+1))p" "$MANIFEST")') + lines.append('TASK_PKL=$(echo "$LINE" | cut -d" " -f1)') + lines.append('RESULT_JSON=$(echo "$LINE" | cut -d" " -f2)') + lines.append( + f'"{self.python_executable}" -m autosampler.execution.run_task ' + '"$TASK_PKL" "$RESULT_JSON"' + ) + return "\n".join(lines) + "\n" + + def _wait_for_completion(self, job_id: str, expected: list[Path]) -> None: + while True: + if all(path.exists() for path in expected): + return + proc = self._run_command(self._poll_command(job_id), self.submit_timeout) + if not self._job_active(job_id, proc.stdout, proc.returncode): + # Job left the queue; give markers a brief grace then stop. + if not all(path.exists() for path in expected): + self._sleep(min(self.poll_interval, 2.0)) + return + self._sleep(self.poll_interval) + + @staticmethod + def _read_success(result_file: Path) -> bool: + if not result_file.exists(): + return False + try: + data = json.loads(result_file.read_text()) + except (json.JSONDecodeError, OSError): + return False + return bool(data.get("success", False)) diff --git a/autosampler/execution/slurm.py b/autosampler/execution/slurm.py new file mode 100644 index 0000000..99ebec9 --- /dev/null +++ b/autosampler/execution/slurm.py @@ -0,0 +1,53 @@ +"""SLURM array-job execution backend.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from .base import ExecutionBackendFactory +from .scheduler import SchedulerBackend + + +class SlurmBackend(SchedulerBackend): + array_index_var = "SLURM_ARRAY_TASK_ID" + + def _directives(self, n_tasks: int, logdir: Path) -> list[str]: + d = [ + f"#SBATCH --job-name={self.job_name}", + f"#SBATCH --array=0-{n_tasks - 1}", + f"#SBATCH --time={self.walltime}", + f"#SBATCH --cpus-per-task={self.cpus_per_task}", + f"#SBATCH --output={logdir}/%A_%a.out", + f"#SBATCH --error={logdir}/%A_%a.err", + ] + if self.partition: + d.append(f"#SBATCH --partition={self.partition}") + if self.account: + d.append(f"#SBATCH --account={self.account}") + if self.gpus_per_task > 0: + d.append(f"#SBATCH --gpus-per-task={self.gpus_per_task}") + if self.memory: + d.append(f"#SBATCH --mem={self.memory}") + d += [line for line in self.extra_directives] + return d + + def _submit_command(self, script_path: Path) -> list[str]: + return ["sbatch", "--parsable", str(script_path)] + + def _parse_job_id(self, stdout: str) -> str: + # `sbatch --parsable` prints just the job id (optionally `id;cluster`). + token = stdout.strip().splitlines()[-1] if stdout.strip() else "" + return token.split(";")[0].strip() + + def _poll_command(self, job_id: str) -> list[str]: + return ["squeue", "--job", job_id, "--noheader", "--array"] + + def _job_active(self, job_id: str, poll_stdout: str, returncode: int) -> bool: + # squeue lists one line per still-active array element; empty => done. + if returncode != 0: + return False + return bool(re.search(rf"\b{re.escape(job_id)}\b", poll_stdout)) + + +ExecutionBackendFactory.register("slurm", SlurmBackend) diff --git a/autosampler/init_cli.py b/autosampler/init_cli.py new file mode 100644 index 0000000..1bb8ce8 --- /dev/null +++ b/autosampler/init_cli.py @@ -0,0 +1,42 @@ +"""CLI: write a starter AutoSampler input file. + + autosampler-init # writes ./config.yaml + autosampler-init -o my.yaml # custom path + autosampler-init --force # overwrite an existing file + +Edit the generated file, then: autosampler --config config.yaml --check +""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path + +from autosampler.templates import DEFAULT_TEMPLATE + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "-o", "--output", type=Path, default=Path("config.yaml"), help="Output path." + ) + parser.add_argument( + "--force", action="store_true", help="Overwrite an existing file." + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> None: + args = parse_args(argv) + if args.output.exists() and not args.force: + raise SystemExit( + f"ERROR: {args.output} already exists. Use --force to overwrite." + ) + args.output.write_text(DEFAULT_TEMPLATE, encoding="utf-8") + print(f"Wrote starter input file: {args.output}") + print(f"Next: edit it, then run autosampler --config {args.output} --check") + + +if __name__ == "__main__": + main() diff --git a/autosampler/msm/__init__.py b/autosampler/msm/__init__.py new file mode 100644 index 0000000..5743915 --- /dev/null +++ b/autosampler/msm/__init__.py @@ -0,0 +1,57 @@ +"""Markov State Model subsystem for AutoSampler. + +This package adds the MSM-building and MSM-based convergence capability that the +adaptive loop uses to decide when sampling is complete: + +- :class:`~autosampler.msm.estimator.MSMEstimator` -- clustering, transition + counting, MLE/Bayesian MSM, implied timescales, VAMP-2, PCCA+. +- :class:`~autosampler.msm.convergence.ConvergenceMonitor` -- pluggable + convergence criteria (ITS stability, VAMP-2 plateau, stationary-distribution + drift, Bayesian statistical error). +- :class:`~autosampler.msm.diagnostics.MSMResult` -- serialisable per-iteration + result container. +""" + +from .convergence import ( + ConvergenceCriterion, + ConvergenceMonitor, + ImpliedTimescaleCriterion, + StationaryDistributionCriterion, + StatisticalErrorCriterion, + TransitionMatrixCriterion, + VAMP2Criterion, + build_criterion, +) +from .diagnostics import ITSResult, MSMResult +from .estimator import MSMEstimator, MSMEstimatorFactory + +__all__ = [ + "MSMEstimator", + "MSMEstimatorFactory", + "MSMResult", + "ITSResult", + "ConvergenceMonitor", + "ConvergenceCriterion", + "ImpliedTimescaleCriterion", + "VAMP2Criterion", + "StationaryDistributionCriterion", + "StatisticalErrorCriterion", + "TransitionMatrixCriterion", + "build_criterion", +] + + +def build_monitor_from_config(msm_config) -> "ConvergenceMonitor": + """Construct a :class:`ConvergenceMonitor` from an ``MSMConfig`` object.""" + criteria = [] + for spec in msm_config.convergence_criteria: + name = spec["name"] if isinstance(spec, dict) else spec.name + kwargs = dict(spec.get("params", {})) if isinstance(spec, dict) else dict( + getattr(spec, "params", {}) or {} + ) + criteria.append(build_criterion(name, **kwargs)) + return ConvergenceMonitor( + criteria, + mode=msm_config.convergence_mode, + patience=msm_config.convergence_patience, + ) diff --git a/autosampler/msm/convergence.py b/autosampler/msm/convergence.py new file mode 100644 index 0000000..cf2069f --- /dev/null +++ b/autosampler/msm/convergence.py @@ -0,0 +1,326 @@ +"""MSM-based convergence detection for the adaptive sampling loop. + +A :class:`ConvergenceMonitor` holds a list of pluggable +:class:`ConvergenceCriterion` objects. Each iteration it is fed the latest +:class:`~autosampler.msm.diagnostics.MSMResult`; it records per-criterion state +and reports convergence once the configured combination of criteria +(``"all"`` / ``"any"``) has held for ``patience`` consecutive iterations. + +The criteria here operate on quantities that are invariant to microstate +relabelling (slow implied timescales, VAMP-2 score, sorted metastable +populations), so they remain valid even though the clustering changes from one +iteration to the next. +""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass + +import numpy as np + +from .diagnostics import MSMResult + +logger = logging.getLogger(__name__) + + +@dataclass +class CriterionStatus: + name: str + satisfied: bool + value: float | None + detail: str + + +class ConvergenceCriterion(ABC): + """Base class for a single convergence test fed one MSMResult per call.""" + + name: str = "criterion" + + @abstractmethod + def update(self, result: MSMResult) -> CriterionStatus: + """Record ``result`` and report whether the test is currently satisfied.""" + + def reset(self) -> None: # noqa: B027 - optional no-op hook, not abstract + """Clear accumulated history (used when restarting a monitor).""" + + +def _relative_change(prev: float, curr: float) -> float: + denom = max(abs(prev), abs(curr), 1e-12) + return abs(curr - prev) / denom + + +class ImpliedTimescaleCriterion(ConvergenceCriterion): + """Satisfied when the slowest ``k`` implied timescales stop changing. + + Compares the current slow timescales against the previous iteration; the + test passes when the maximum relative change is below ``tol``. + """ + + name = "implied_timescales" + + def __init__(self, tol: float = 0.1, n_timescales: int = 2) -> None: + self.tol = float(tol) + self.n_timescales = int(n_timescales) + self._prev: np.ndarray | None = None + + def reset(self) -> None: + self._prev = None + + def update(self, result: MSMResult) -> CriterionStatus: + ts = np.asarray(result.timescales, dtype=float) + ts = ts[np.isfinite(ts)][: self.n_timescales] + if ts.size == 0: + return CriterionStatus(self.name, False, None, "no finite timescales") + if self._prev is None or self._prev.size != ts.size: + self._prev = ts + return CriterionStatus(self.name, False, None, "baseline established") + changes = [ + _relative_change(p, c) for p, c in zip(self._prev, ts, strict=False) + ] + max_change = float(max(changes)) + self._prev = ts + satisfied = max_change < self.tol + return CriterionStatus( + self.name, + satisfied, + max_change, + f"max rel. change {max_change:.3f} (tol {self.tol})", + ) + + +class VAMP2Criterion(ConvergenceCriterion): + """Satisfied when the VAMP-2 score plateaus (relative change below ``tol``).""" + + name = "vamp2" + + def __init__(self, tol: float = 0.05) -> None: + self.tol = float(tol) + self._prev: float | None = None + + def reset(self) -> None: + self._prev = None + + def update(self, result: MSMResult) -> CriterionStatus: + score = result.vamp2_score + if score is None: + return CriterionStatus(self.name, False, None, "no VAMP-2 score") + if self._prev is None: + self._prev = score + return CriterionStatus(self.name, False, score, "baseline established") + change = _relative_change(self._prev, score) + self._prev = score + satisfied = change < self.tol + return CriterionStatus( + self.name, satisfied, change, f"rel. change {change:.3f} (tol {self.tol})" + ) + + +class StationaryDistributionCriterion(ConvergenceCriterion): + """Satisfied when metastable populations stabilise across iterations. + + Uses the PCCA+ metastable populations (sorted, so the test is invariant to + macrostate relabelling) and measures L1 drift between consecutive + iterations. Falls back to inactivity when no metastable decomposition is + available. + """ + + name = "stationary_distribution" + + def __init__(self, tol: float = 0.05) -> None: + self.tol = float(tol) + self._prev: np.ndarray | None = None + + def reset(self) -> None: + self._prev = None + + def update(self, result: MSMResult) -> CriterionStatus: + pops = result.metastable_populations + if pops is None: + return CriterionStatus( + self.name, False, None, "no metastable populations (set n_metastable)" + ) + pops = np.sort(np.asarray(pops, dtype=float))[::-1] + if self._prev is None or self._prev.size != pops.size: + self._prev = pops + return CriterionStatus(self.name, False, None, "baseline established") + drift = float(np.abs(pops - self._prev).sum()) + self._prev = pops + satisfied = drift < self.tol + return CriterionStatus( + self.name, satisfied, drift, f"L1 drift {drift:.3f} (tol {self.tol})" + ) + + +class StatisticalErrorCriterion(ConvergenceCriterion): + """Satisfied when the Bayesian relative error on the slowest timescale is low. + + Requires ``estimator: bayesian`` so that ``timescale_errors`` is populated. + """ + + name = "statistical_error" + + def __init__(self, tol: float = 0.1) -> None: + self.tol = float(tol) + + def update(self, result: MSMResult) -> CriterionStatus: + errors = result.timescale_errors + ts = result.slowest_timescale + if errors is None or ts is None or not np.isfinite(ts) or ts == 0: + return CriterionStatus( + self.name, False, None, "no Bayesian errors (set estimator=bayesian)" + ) + rel_err = float(np.asarray(errors, dtype=float)[0] / abs(ts)) + satisfied = rel_err < self.tol + return CriterionStatus( + self.name, satisfied, rel_err, f"rel. error {rel_err:.3f} (tol {self.tol})" + ) + + +class TransitionMatrixCriterion(ConvergenceCriterion): + """Satisfied when every *significant* transition probability is well determined. + + Uses the connected count matrix to get an analytic Dirichlet uncertainty on + each transition probability, ``σ(T_ij) = sqrt(T_ij(1−T_ij)/(c_i+1))`` (no + bootstrap). The test passes when the largest **flux-weighted** relative + uncertainty over significant transitions is below ``tol``: + ``max_{(i,j): π_i T_ij > min_flux·max} σ(T_ij)/T_ij < tol``. Flux weighting + avoids being dominated by noise in tiny, irrelevant entries. This is a + within-iteration *absolute* statistical-convergence test; combine it with the + spectral criteria under ``mode: all`` to require both kinetic resolution and + statistical convergence of the microstate transition matrix. + + Requires ``count_matrix`` on the result (always populated by MSMEstimator). + """ + + name = "transition_matrix" + + def __init__(self, tol: float = 0.2, min_flux: float = 1e-4) -> None: + self.tol = float(tol) + self.min_flux = float(min_flux) + + def update(self, result: MSMResult) -> CriterionStatus: + T = result.transition_matrix + pi = result.stationary_distribution + C = result.count_matrix + if T is None or pi is None or C is None: + return CriterionStatus( + self.name, False, None, "needs count_matrix (MSM not built yet)" + ) + T = np.asarray(T, dtype=float) + pi = np.asarray(pi, dtype=float) + counts = np.asarray(C, dtype=float).sum(axis=1) # row counts c_i + var = T * (1.0 - T) / (counts[:, None] + 1.0) # Dirichlet element variance + sigma = np.sqrt(np.clip(var, 0.0, None)) + flux = pi[:, None] * T + with np.errstate(divide="ignore", invalid="ignore"): + rel = np.where(T > 0, sigma / T, 0.0) + fmax = float(flux.max()) if flux.size else 0.0 + mask = flux > (self.min_flux * fmax if fmax > 0 else self.min_flux) + metric = float(np.max(rel[mask])) if np.any(mask) else 0.0 + satisfied = metric < self.tol + return CriterionStatus( + self.name, + satisfied, + metric, + f"max flux-weighted rel. error {metric:.3f} (tol {self.tol})", + ) + + +_CRITERION_REGISTRY = { + ImpliedTimescaleCriterion.name: ImpliedTimescaleCriterion, + VAMP2Criterion.name: VAMP2Criterion, + StationaryDistributionCriterion.name: StationaryDistributionCriterion, + StatisticalErrorCriterion.name: StatisticalErrorCriterion, + TransitionMatrixCriterion.name: TransitionMatrixCriterion, +} + + +def build_criterion(name: str, **kwargs) -> ConvergenceCriterion: + if name not in _CRITERION_REGISTRY: + raise ValueError( + f"Unknown convergence criterion {name!r}; " + f"available: {sorted(_CRITERION_REGISTRY)}" + ) + return _CRITERION_REGISTRY[name](**kwargs) + + +class ConvergenceMonitor: + """Aggregate pluggable criteria into a single converged / not-converged signal. + + Parameters + ---------- + criteria: + List of :class:`ConvergenceCriterion` instances. + mode: + ``"all"`` (default) requires every criterion satisfied simultaneously; + ``"any"`` requires at least one. + patience: + Number of *consecutive* iterations the combination must hold before the + monitor reports convergence. + """ + + def __init__( + self, + criteria: list[ConvergenceCriterion], + mode: str = "all", + patience: int = 2, + ) -> None: + if not criteria: + raise ValueError("ConvergenceMonitor requires at least one criterion.") + if mode not in ("all", "any"): + raise ValueError("mode must be 'all' or 'any'.") + self.criteria = criteria + self.mode = mode + self.patience = int(patience) + self.streak = 0 + self.converged = False + self.reason: str | None = None + self.last_statuses: list[CriterionStatus] = [] + + def update(self, result: MSMResult) -> bool: + """Feed a new MSMResult; return whether convergence is now declared.""" + statuses = [c.update(result) for c in self.criteria] + self.last_statuses = statuses + flags = [s.satisfied for s in statuses] + combined = all(flags) if self.mode == "all" else any(flags) + + if combined: + self.streak += 1 + else: + self.streak = 0 + + if not self.converged and self.streak >= self.patience: + self.converged = True + detail = "; ".join(f"{s.name}: {s.detail}" for s in statuses) + self.reason = ( + f"MSM convergence: criteria ({self.mode}) satisfied for " + f"{self.streak} consecutive iteration(s). [{detail}]" + ) + logger.info(self.reason) + return self.converged + + def status_line(self) -> str: + parts = [ + f"{s.name}={'ok' if s.satisfied else 'no'}" + + (f"({s.value:.3f})" if s.value is not None else "") + for s in self.last_statuses + ] + return f"streak={self.streak}/{self.patience} " + " ".join(parts) + + def state_dict(self) -> dict: + return { + "mode": self.mode, + "patience": self.patience, + "streak": self.streak, + "converged": self.converged, + "reason": self.reason, + } + + def load_state_dict(self, state: dict) -> None: + if not state: + return + self.streak = int(state.get("streak", 0)) + self.converged = bool(state.get("converged", False)) + self.reason = state.get("reason") diff --git a/autosampler/msm/diagnostics.py b/autosampler/msm/diagnostics.py new file mode 100644 index 0000000..02c1fe6 --- /dev/null +++ b/autosampler/msm/diagnostics.py @@ -0,0 +1,156 @@ +"""Serializable diagnostic containers for Markov State Model estimation. + +These dataclasses hold the per-iteration MSM outputs (timescales, stationary +distribution, scores, implied-timescale sweeps, metastable decomposition) in a +form that is cheap to checkpoint (``to_dict`` / ``from_dict`` round-trip through +plain Python / NumPy objects) and convenient to feed into convergence checks, +logging and plotting. + +The module deliberately avoids importing ``deeptime`` at import time; estimation +lives in :mod:`autosampler.msm.estimator`. Here we only describe the *results*. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + +import numpy as np + + +def _to_array(value: Any) -> np.ndarray | None: + if value is None: + return None + return np.asarray(value, dtype=float) + + +@dataclass +class ITSResult: + """Implied-timescale sweep across a set of lag times. + + Attributes + ---------- + lagtimes: + Lag times (in frames) at which MSMs were estimated. + timescales: + Array of shape ``(n_lagtimes, n_processes)`` with the implied + timescales of the slowest processes at each lag time. ``NaN`` entries + indicate a process that could not be resolved at that lag time. + """ + + lagtimes: np.ndarray + timescales: np.ndarray + + def to_dict(self) -> dict[str, Any]: + return { + "lagtimes": np.asarray(self.lagtimes).tolist(), + "timescales": np.asarray(self.timescales).tolist(), + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ITSResult: + return cls( + lagtimes=np.asarray(data["lagtimes"]), + timescales=np.asarray(data["timescales"], dtype=float), + ) + + +@dataclass +class MSMResult: + """Outputs of a single MSM estimation over the current CV/latent space. + + All fields are plain NumPy / Python objects so the result serialises + cleanly into ``iter_*/msm.npz`` and into the run checkpoint. + """ + + lagtime: int + n_microstates: int + n_states_active: int + timescales: np.ndarray + stationary_distribution: np.ndarray + transition_matrix: np.ndarray + cluster_centers: np.ndarray + counts_per_state: np.ndarray + vamp2_score: float | None = None + estimator: str = "mle" + iteration: int | None = None + n_metastable: int | None = None + metastable_assignments: np.ndarray | None = None + metastable_populations: np.ndarray | None = None + its: ITSResult | None = None + # Bayesian statistical errors on the slowest timescales (std over posterior). + timescale_errors: np.ndarray | None = None + # Connected-set count matrix C_ij (for Dirichlet transition-prob uncertainty). + count_matrix: np.ndarray | None = None + # Right eigenvectors of the slow processes, shape (n_active, k) (ψ₂..). + eigenvectors: np.ndarray | None = None + # Original cluster ids retained in the connected set; maps cluster id -> + # active-state index for MSM-guided spawning on the shared clustering. + state_symbols: np.ndarray | None = None + extra: dict[str, Any] = field(default_factory=dict) + + @property + def slowest_timescale(self) -> float | None: + ts = np.asarray(self.timescales, dtype=float) + finite = ts[np.isfinite(ts)] + return float(finite[0]) if finite.size else None + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + # asdict recurses into the ITSResult dataclass; normalise to its dict. + if self.its is not None: + data["its"] = self.its.to_dict() + for key in ( + "timescales", + "stationary_distribution", + "transition_matrix", + "cluster_centers", + "counts_per_state", + "metastable_assignments", + "metastable_populations", + "timescale_errors", + "count_matrix", + "eigenvectors", + "state_symbols", + ): + value = getattr(self, key) + data[key] = None if value is None else np.asarray(value).tolist() + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> MSMResult: + data = dict(data) + its = data.get("its") + if isinstance(its, dict): + data["its"] = ITSResult.from_dict(its) + for key in ( + "timescales", + "stationary_distribution", + "transition_matrix", + "cluster_centers", + "counts_per_state", + ): + data[key] = _to_array(data.get(key)) + for key in ( + "metastable_assignments", + "metastable_populations", + "timescale_errors", + "count_matrix", + "eigenvectors", + "state_symbols", + ): + value = data.get(key) + data[key] = None if value is None else _to_array(value) + # Drop any unexpected keys so the dataclass stays forward-compatible. + allowed = set(cls.__dataclass_fields__) + data = {k: v for k, v in data.items() if k in allowed} + return cls(**data) + + def summary(self) -> str: + ts = np.asarray(self.timescales, dtype=float) + ts_str = ", ".join(f"{t:.1f}" if np.isfinite(t) else "nan" for t in ts[:3]) + score_str = "n/a" if self.vamp2_score is None else f"{self.vamp2_score:.3f}" + return ( + f"MSM(lag={self.lagtime}, active_states={self.n_states_active}/" + f"{self.n_microstates}, t2..={ts_str}, VAMP2={score_str})" + ) diff --git a/autosampler/msm/estimator.py b/autosampler/msm/estimator.py new file mode 100644 index 0000000..dadd815 --- /dev/null +++ b/autosampler/msm/estimator.py @@ -0,0 +1,367 @@ +"""Markov State Model estimation over the AutoSampler CV / latent space. + +``MSMEstimator`` wraps :mod:`deeptime.markov` into a small, testable API that the +adaptive loop can call once per iteration: + + estimator = MSMEstimator(lagtime=10, n_microstates=100) + result = estimator.fit(trajs) # trajs: list of (n_frames_i, n_cv) + +It performs: clustering of the (continuous, per-walker) projections into +microstates -> sliding-window transition counts -> restriction to the largest +connected set -> maximum-likelihood (or Bayesian) MSM -> implied timescales, +VAMP-2 score and PCCA+ metastable decomposition. The output is a serialisable +:class:`~autosampler.msm.diagnostics.MSMResult`. + +``deeptime`` is imported lazily so that importing this module never hard-requires +it; a clear error is raised only when estimation is actually attempted. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from typing import Any + +import numpy as np + +from .diagnostics import ITSResult, MSMResult + +logger = logging.getLogger(__name__) + +_VALID_CLUSTER = ("kmeans", "regspace") +_VALID_ESTIMATOR = ("mle", "bayesian") + + +def _require_deeptime(): + try: + import deeptime # noqa: F401 + except ImportError as exc: # pragma: no cover - environment dependent + raise ImportError( + "MSM estimation requires the 'deeptime' package. Install it with " + "`pip install deeptime` (it is already declared as an AutoSampler " + "dependency)." + ) from exc + + +def _as_traj_list(trajs: Sequence[np.ndarray]) -> list[np.ndarray]: + out: list[np.ndarray] = [] + for traj in trajs: + arr = np.asarray(traj, dtype=float) + if arr.ndim == 1: + arr = arr.reshape(-1, 1) + if arr.shape[0] == 0: + continue + out.append(arr) + if not out: + raise ValueError("MSMEstimator received no non-empty trajectories.") + return out + + +class MSMEstimator: + """Estimate an MSM from a list of continuous CV trajectories. + + Parameters + ---------- + lagtime: + Lag time (in saved frames) used for the production MSM. + n_microstates: + Number of clusters (microstates) used to discretise the CV space. + cluster_method: + ``"kmeans"`` or ``"regspace"`` (regular-space clustering). + estimator: + ``"mle"`` (maximum likelihood) or ``"bayesian"`` (adds posterior error + bars on the slow timescales via :class:`deeptime.markov.msm.BayesianMSM`). + n_metastable: + If set, run PCCA+ to coarse-grain into this many metastable states. + n_timescales: + Number of slow processes (implied timescales) to track. + lagtimes: + Optional lag-time ladder for an implied-timescale sweep; when provided, + :meth:`fit` attaches an :class:`ITSResult` to the output. + n_bayesian_samples: + Posterior sample count for the Bayesian estimator. + seed: + Random seed forwarded to the clustering for reproducibility. + """ + + def __init__( + self, + lagtime: int = 10, + n_microstates: int = 100, + cluster_method: str = "kmeans", + estimator: str = "mle", + n_metastable: int | None = None, + n_timescales: int = 3, + lagtimes: Sequence[int] | None = None, + n_bayesian_samples: int = 50, + regspace_dmin: float | None = None, + stable_clustering: bool = False, + seed: int = 42, + **_: Any, + ) -> None: + self.lagtime = int(lagtime) + self.n_microstates = int(n_microstates) + self.cluster_method = str(cluster_method).lower() + self.estimator = str(estimator).lower() + self.n_metastable = None if n_metastable is None else int(n_metastable) + self.n_timescales = int(n_timescales) + self.lagtimes = [int(lt) for lt in lagtimes] if lagtimes else None + self.n_bayesian_samples = int(n_bayesian_samples) + self.regspace_dmin = regspace_dmin + self.stable_clustering = bool(stable_clustering) + self.seed = int(seed) + + if self.lagtime <= 0: + raise ValueError("MSM lagtime must be a positive integer.") + if self.n_microstates <= 1: + raise ValueError("n_microstates must be greater than 1.") + if self.cluster_method not in _VALID_CLUSTER: + raise ValueError(f"cluster_method must be one of {_VALID_CLUSTER}.") + if self.estimator not in _VALID_ESTIMATOR: + raise ValueError(f"estimator must be one of {_VALID_ESTIMATOR}.") + + self._cluster_model = None # last fitted clustering (deeptime model) + + # ------------------------------------------------------------------ # + # Clustering + # ------------------------------------------------------------------ # + def cluster(self, trajs: Sequence[np.ndarray]): + """Discretise CV trajectories into microstate index trajectories. + + Returns ``(dtrajs, cluster_model)`` where ``dtrajs`` is a list of int + arrays (one per input trajectory) and ``cluster_model`` exposes + ``cluster_centers``. + """ + _require_deeptime() + traj_list = _as_traj_list(trajs) + stacked = np.vstack(traj_list) + n_clusters = min(self.n_microstates, stacked.shape[0]) + + if self.cluster_method == "kmeans": + from deeptime.clustering import KMeans + + kmeans_kwargs: dict[str, Any] = {} + # Stable clustering: seed from the previous centres so microstate IDs + # stay comparable across iterations (needed for T_ij convergence and + # MSM-guided spawning on a shared clustering). + prev = getattr(self._cluster_model, "cluster_centers", None) + if self.stable_clustering and prev is not None and len(prev) == n_clusters: + kmeans_kwargs["initial_centers"] = np.asarray(prev, dtype=float) + estimator = KMeans( + n_clusters=n_clusters, + max_iter=200, + fixed_seed=self.seed, + progress=None, + **kmeans_kwargs, + ) + self._cluster_model = estimator.fit_fetch(stacked) + else: # regspace + from deeptime.clustering import RegularSpace + + dmin = self.regspace_dmin + if dmin is None: + # Heuristic: spread the requested microstate budget over the + # data extent so regular-space clustering yields ~n_microstates. + extent = float(np.linalg.norm(stacked.max(0) - stacked.min(0))) + dmin = max(extent / max(n_clusters, 1), 1e-6) + estimator = RegularSpace(dmin=dmin, max_centers=n_clusters) + self._cluster_model = estimator.fit_fetch(stacked) + + dtrajs = [ + np.asarray(self._cluster_model.transform(t), dtype=np.int64) + for t in traj_list + ] + return dtrajs, self._cluster_model + + # ------------------------------------------------------------------ # + # MSM estimation + # ------------------------------------------------------------------ # + def _count_model(self, dtrajs: list[np.ndarray], lagtime: int): + from deeptime.markov import TransitionCountEstimator + + count_mode = "effective" if self.estimator == "bayesian" else "sliding" + counts = TransitionCountEstimator( + lagtime=lagtime, count_mode=count_mode + ).fit_fetch(dtrajs) + return counts.submodel_largest() + + def _fit_msm(self, connected_counts): + from deeptime.markov.msm import BayesianMSM, MaximumLikelihoodMSM + + if self.estimator == "bayesian": + posterior = BayesianMSM( + n_samples=self.n_bayesian_samples + ).fit_fetch(connected_counts) + return posterior + return MaximumLikelihoodMSM().fit_fetch(connected_counts) + + def fit(self, trajs: Sequence[np.ndarray], iteration: int | None = None) -> MSMResult: + """Cluster, estimate the MSM and return a serialisable result.""" + _require_deeptime() + dtrajs, cluster_model = self.cluster(trajs) + + connected = self._count_model(dtrajs, self.lagtime) + if connected.n_states < 2: + raise RuntimeError( + "MSM connected set has fewer than 2 states; sampling is too " + "sparse or lagtime too large for a Markov model at this stage." + ) + + fitted = self._fit_msm(connected) + msm, timescale_errors = self._unwrap(fitted) + + k = min(self.n_timescales, msm.n_states - 1) + timescales = np.asarray(msm.timescales(k=k), dtype=float) + + vamp2 = self._safe_score(msm, dtrajs) + n_meta, meta_assign, meta_pop = self._pcca(msm) + + # Transition-matrix uncertainty / leverage inputs (best-effort). + count_matrix = self._safe_count_matrix(connected) + state_symbols = self._safe_state_symbols(connected) + eigenvectors = self._safe_eigenvectors(msm, k) + + its = None + if self.lagtimes: + its = self.implied_timescales(dtrajs, self.lagtimes) + + return MSMResult( + lagtime=self.lagtime, + n_microstates=int(getattr(cluster_model, "n_clusters", self.n_microstates)), + n_states_active=int(msm.n_states), + timescales=timescales, + stationary_distribution=np.asarray(msm.stationary_distribution, dtype=float), + transition_matrix=np.asarray(msm.transition_matrix, dtype=float), + cluster_centers=np.asarray(cluster_model.cluster_centers, dtype=float), + counts_per_state=np.asarray(connected.state_histogram, dtype=float), + vamp2_score=vamp2, + estimator=self.estimator, + iteration=iteration, + n_metastable=n_meta, + metastable_assignments=meta_assign, + metastable_populations=meta_pop, + its=its, + timescale_errors=timescale_errors, + count_matrix=count_matrix, + eigenvectors=eigenvectors, + state_symbols=state_symbols, + ) + + def implied_timescales( + self, dtrajs: list[np.ndarray], lagtimes: Sequence[int] + ) -> ITSResult: + """Estimate implied timescales across a ladder of lag times.""" + from deeptime.markov.msm import MaximumLikelihoodMSM + from deeptime.util.validation import implied_timescales + + models = [] + used = [] + for lag in lagtimes: + try: + cm = self._count_model(dtrajs, int(lag)) + if cm.n_states < 2: + continue + models.append(MaximumLikelihoodMSM().fit_fetch(cm)) + used.append(int(lag)) + except Exception as exc: # noqa: BLE001 - skip unusable lag times + logger.debug("ITS lagtime %s skipped: %s", lag, exc) + if not models: + return ITSResult(lagtimes=np.asarray([]), timescales=np.zeros((0, 0))) + + its = implied_timescales(models) + n_proc = min(self.n_timescales, its.max_n_processes) + matrix = np.full((len(its.lagtimes), n_proc), np.nan) + for p in range(n_proc): + matrix[:, p] = np.asarray(its.timescales_for_process(p), dtype=float) + return ITSResult(lagtimes=np.asarray(its.lagtimes), timescales=matrix) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _unwrap(self, fitted): + """Return ``(point_estimate_msm, timescale_errors_or_None)``.""" + if self.estimator != "bayesian": + return fitted, None + prior = fitted.prior + try: + k = min(self.n_timescales, prior.n_states - 1) + sample_ts = np.array( + [np.asarray(m.timescales(k=k), dtype=float) for m in fitted.samples] + ) + errors = np.nanstd(sample_ts, axis=0) + except Exception as exc: # noqa: BLE001 + logger.debug("Bayesian timescale error estimation failed: %s", exc) + errors = None + return prior, errors + + @staticmethod + def _safe_count_matrix(connected) -> np.ndarray | None: + try: + return np.asarray(connected.count_matrix, dtype=float) + except Exception as exc: # noqa: BLE001 - best-effort + logger.debug("count_matrix unavailable: %s", exc) + return None + + @staticmethod + def _safe_state_symbols(connected) -> np.ndarray | None: + try: + return np.asarray(connected.state_symbols, dtype=int) + except Exception as exc: # noqa: BLE001 - best-effort + logger.debug("state_symbols unavailable: %s", exc) + return None + + @staticmethod + def _safe_eigenvectors(msm, k: int) -> np.ndarray | None: + """Right eigenvectors of the k slowest processes (skip the stationary ψ₁).""" + try: + vecs = np.asarray(msm.eigenvectors_right(k + 1), dtype=float) + return vecs[:, 1:] if vecs.ndim == 2 and vecs.shape[1] > 1 else None + except Exception as exc: # noqa: BLE001 - best-effort + logger.debug("eigenvectors unavailable: %s", exc) + return None + + @staticmethod + def _safe_score(msm, dtrajs) -> float | None: + try: + return float(msm.score(dtrajs=dtrajs, r=2)) + except Exception as exc: # noqa: BLE001 - scoring is best-effort + logger.debug("VAMP-2 scoring failed: %s", exc) + return None + + def _pcca(self, msm): + if not self.n_metastable or self.n_metastable < 2: + return None, None, None + if msm.n_states <= self.n_metastable: + return None, None, None + try: + pcca = msm.pcca(self.n_metastable) + assignments = np.asarray(pcca.assignments, dtype=int) + populations = np.asarray( + [ + msm.stationary_distribution[assignments == m].sum() + for m in range(self.n_metastable) + ], + dtype=float, + ) + return self.n_metastable, assignments, populations + except Exception as exc: # noqa: BLE001 - PCCA+ is best-effort + logger.debug("PCCA+ failed: %s", exc) + return None, None, None + + +class MSMEstimatorFactory: + """Registry for MSM estimator variants, mirroring SpawnerFactory/EngineFactory.""" + + _registry: dict[str, type] = {} + + @classmethod + def register(cls, name: str, estimator_cls: type) -> None: + cls._registry[name] = estimator_cls + + @classmethod + def get(cls, name: str = "default", **kwargs: Any) -> MSMEstimator: + estimator_cls = cls._registry.get(name, MSMEstimator) + return estimator_cls(**kwargs) + + +MSMEstimatorFactory.register("default", MSMEstimator) diff --git a/autosampler/reporting.py b/autosampler/reporting.py new file mode 100644 index 0000000..ddf35e7 --- /dev/null +++ b/autosampler/reporting.py @@ -0,0 +1,57 @@ +"""Terminal presentation for per-iteration progress. + +Keeps the ANSI / tabular UI out of :class:`~autosampler.core.AutoSamplerCore` +so the orchestrator focuses on the sampling logic. Pure formatting: easy to +unit-test and to swap for a richer reporter later. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +_CYAN = "\033[96m" +_RED = "\033[91m" +_END = "\033[0m" + + +@dataclass +class IterationReporter: + """Render the boxed per-iteration summary banner.""" + + width: int = 85 + + def format_summary( + self, + iteration: int, + runner_time: float, + other_time: float, + occupancy: str, + color: bool = True, + ) -> str: + raw = ( + f" Iteration: {iteration:<4} | Runner: {runner_time:<6.2f}s | " + f"Other: {other_time:<5.2f}s | Occupancy: {occupancy:<9}" + ) + if color: + body = ( + f" Iteration: {_CYAN}{iteration:<4}{_END} | " + f"Runner: {_CYAN}{runner_time:<6.2f}s{_END} | " + f"Other: {_CYAN}{other_time:<5.2f}s{_END} | " + f"Occupancy: {_CYAN}{occupancy:<9}{_END}" + ) + red, end = _RED, _END + else: + body = raw + red = end = "" + + left = (self.width - len(raw)) // 2 + right = self.width - len(raw) - left + top = f"\t{red}╔" + "═" * self.width + f"╗\n{end}" + middle = ( + f"\t{red}║{end}" + " " * left + body + " " * right + f"{red}║\n{end}" + ) + bottom = f"\t{red}╚" + "═" * self.width + f"╝{end}" + return top + middle + bottom + + def print_summary(self, *args, **kwargs) -> None: + print(self.format_summary(*args, **kwargs)) diff --git a/autosampler/spaces/__init__.py b/autosampler/spaces/__init__.py index 9cddefa..00a6b9b 100644 --- a/autosampler/spaces/__init__.py +++ b/autosampler/spaces/__init__.py @@ -1,4 +1,44 @@ -from .scalers import TrajectoryScaler -from .tvae import TVAEBottleneckEncoder, TVAEBottleneckDecoder -from .features import FeatureExtractor -from .model import AdaptiveSpaceModel +"""Spaces subpackage: CV / dimensionality-reduction models and feature extraction. + +Heavy or optional dependencies (MDAnalysis, torch, deeptime) are imported +**lazily** so that lightweight modules such as +:mod:`autosampler.spaces.registry` can be imported without them — e.g. in +minimal CI environments or when only the CV-method metadata is needed. +""" + +from importlib import import_module +from typing import TYPE_CHECKING + +__all__ = [ + "TrajectoryScaler", + "TVAEBottleneckEncoder", + "TVAEBottleneckDecoder", + "FeatureExtractor", + "AdaptiveSpaceModel", +] + +_LAZY = { + "TrajectoryScaler": ".scalers", + "TVAEBottleneckEncoder": ".tvae", + "TVAEBottleneckDecoder": ".tvae", + "FeatureExtractor": ".features", + "AdaptiveSpaceModel": ".model", +} + + +def __getattr__(name: str): + module = _LAZY.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return getattr(import_module(module, __name__), name) + + +def __dir__(): + return sorted(__all__) + + +if TYPE_CHECKING: # pragma: no cover - import hints for type checkers only + from .features import FeatureExtractor + from .model import AdaptiveSpaceModel + from .scalers import TrajectoryScaler + from .tvae import TVAEBottleneckDecoder, TVAEBottleneckEncoder diff --git a/autosampler/spaces/feature_selection.py b/autosampler/spaces/feature_selection.py new file mode 100644 index 0000000..2e6f323 --- /dev/null +++ b/autosampler/spaces/feature_selection.py @@ -0,0 +1,200 @@ +"""VAMP-2 based input-feature selection and optimisation. + +The quality of an MSM/CV is bounded by the input features fed to it. VAMP-2 is a +variational score for how well a feature set captures the slow dynamics: higher +is better, and it can be compared across *different* feature sets on the same +trajectories (Wu & Noé, 2017; Scherer et al., 2019). + +This module provides: + +- :func:`vamp2_score` — a dependency-light VAMP-2 score from time-lagged + covariances of feature trajectories. +- :func:`rank_candidates` — rank named candidate feature sets by VAMP-2. +- :func:`greedy_vamp_selection` — greedy forward selection of the feature + *columns* (or column groups) that maximise VAMP-2, i.e. an optimisation + protocol that picks the best subset of features. +- :class:`FeatureSelector` — thin orchestrator used by the adaptive loop to + choose and periodically update the input features. + +All functions operate on plain arrays, so they are testable without MD inputs. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +def _lagged_pairs(trajs: list[np.ndarray], lagtime: int): + """Stack instantaneous/time-lagged frame pairs across trajectories.""" + inst, lagged = [], [] + for traj in trajs: + traj = np.asarray(traj, dtype=np.float64) + if traj.ndim == 1: + traj = traj.reshape(-1, 1) + if len(traj) > lagtime: + inst.append(traj[:-lagtime]) + lagged.append(traj[lagtime:]) + if not inst: + raise ValueError( + f"No trajectory is longer than the lag time ({lagtime}); " + "cannot compute a VAMP score." + ) + return np.vstack(inst), np.vstack(lagged) + + +def _whiten(cov: np.ndarray, epsilon: float) -> np.ndarray: + """Return cov^{-1/2} via symmetric eigendecomposition, dropping tiny modes.""" + cov = 0.5 * (cov + cov.T) + vals, vecs = np.linalg.eigh(cov) + keep = vals > epsilon * max(vals.max(), 1e-12) + vals, vecs = vals[keep], vecs[:, keep] + return vecs @ np.diag(1.0 / np.sqrt(vals)) @ vecs.T + + +def vamp2_score( + trajs: list[np.ndarray], + lagtime: int, + dim: int | None = None, + epsilon: float = 1e-6, +) -> float: + """VAMP-2 score of feature ``trajs`` at ``lagtime``. + + Defined as the sum of squared singular values of the whitened time-lagged + correlation (Koopman) matrix ``C00^{-1/2} C0t C11^{-1/2}`` on mean-free + features. Larger means the features resolve more, slower kinetic variance. + ``dim`` optionally caps the number of singular values retained. + """ + inst, lagged = _lagged_pairs(trajs, lagtime) + mean = 0.5 * (inst.mean(axis=0) + lagged.mean(axis=0)) + inst = inst - mean + lagged = lagged - mean + n = len(inst) + + c00 = inst.T @ inst / n + c11 = lagged.T @ lagged / n + c0t = inst.T @ lagged / n + + koopman = _whiten(c00, epsilon) @ c0t @ _whiten(c11, epsilon) + singular_values = np.linalg.svd(koopman, compute_uv=False) + if dim is not None: + singular_values = singular_values[:dim] + # Clip for numerical noise; true singular values of the Koopman op are <= 1. + singular_values = np.clip(singular_values, 0.0, 1.0) + return float(np.sum(singular_values**2)) + + +def rank_candidates( + candidates: dict[str, list[np.ndarray]], + lagtime: int, + dim: int | None = None, +) -> list[tuple[str, float]]: + """Rank named candidate feature sets by VAMP-2 (best first).""" + scored: list[tuple[str, float]] = [] + for name, trajs in candidates.items(): + try: + scored.append((name, vamp2_score(trajs, lagtime, dim=dim))) + except (ValueError, np.linalg.LinAlgError): + scored.append((name, float("-inf"))) + scored.sort(key=lambda item: item[1], reverse=True) + return scored + + +def greedy_vamp_selection( + trajs: list[np.ndarray], + lagtime: int, + groups: list[list[int]] | None = None, + max_groups: int | None = None, + dim: int | None = None, + min_gain: float = 1e-4, +) -> list[int]: + """Greedy forward selection of feature columns maximising VAMP-2. + + Starting from an empty set, repeatedly add the column group whose inclusion + most increases the VAMP-2 score, stopping when no group improves the score + by more than ``min_gain`` (or ``max_groups`` are selected). Returns the + sorted list of selected column indices. + """ + n_features = np.asarray(trajs[0]).reshape(len(trajs[0]), -1).shape[1] + if groups is None: + groups = [[i] for i in range(n_features)] + remaining = list(range(len(groups))) + chosen: list[int] = [] + selected_cols: list[int] = [] + best_score = 0.0 + limit = max_groups if max_groups is not None else len(groups) + + while remaining and len(chosen) < limit: + best_gain, best_g = min_gain, None + for g in remaining: + cols = sorted(selected_cols + groups[g]) + score = vamp2_score([t[:, cols] for t in trajs], lagtime, dim=dim) + if score - best_score > best_gain: + best_gain, best_g, best_cols = score - best_score, g, cols + if best_g is None: + break + chosen.append(best_g) + remaining.remove(best_g) + selected_cols = best_cols + best_score += best_gain + + return sorted(selected_cols) if selected_cols else list(range(n_features)) + + +@dataclass +class FeatureSelection: + """Outcome of a feature-selection step (serialisable for checkpoints).""" + + columns: list[int] + score: float + method: str + + def to_dict(self) -> dict: + return {"columns": list(self.columns), "score": self.score, "method": self.method} + + @classmethod + def from_dict(cls, data: dict) -> FeatureSelection: + return cls( + columns=list(data["columns"]), + score=float(data["score"]), + method=str(data.get("method", "greedy_vamp")), + ) + + +class FeatureSelector: + """Choose the best input-feature columns by VAMP-2 optimisation. + + Used by the adaptive loop when ``feature_selection.enabled`` is set. Operates + on a feature matrix reshaped into per-walker trajectories. + """ + + def __init__( + self, + lagtime: int = 10, + method: str = "greedy_vamp", + max_features: int | None = None, + dim: int | None = None, + min_gain: float = 1e-4, + ): + self.lagtime = int(lagtime) + self.method = method + self.max_features = max_features + self.dim = dim + self.min_gain = float(min_gain) + + def select(self, trajs: list[np.ndarray]) -> FeatureSelection: + if self.method == "greedy_vamp": + cols = greedy_vamp_selection( + trajs, + self.lagtime, + max_groups=self.max_features, + dim=self.dim, + min_gain=self.min_gain, + ) + elif self.method == "all": + cols = list(range(np.asarray(trajs[0]).reshape(len(trajs[0]), -1).shape[1])) + else: + raise ValueError(f"Unknown feature-selection method: {self.method!r}") + score = vamp2_score([t[:, cols] for t in trajs], self.lagtime, dim=self.dim) + return FeatureSelection(columns=cols, score=score, method=self.method) diff --git a/autosampler/spaces/model.py b/autosampler/spaces/model.py index 8b84067..60a15d2 100644 --- a/autosampler/spaces/model.py +++ b/autosampler/spaces/model.py @@ -22,6 +22,8 @@ class AdaptiveSpaceModel: "decoder_hidden_dims": [128, 256], "dropout_rate": 0.1, "deep_tica_hidden_dims": [256, 128], + "spib_n_states": 10, + "spib_beta": 1e-3, } def __init__( @@ -36,6 +38,8 @@ def __init__( decoder_hidden_dims: list[int] | None = None, dropout_rate: float = 0.1, deep_tica_hidden_dims: list[int] | None = None, + spib_n_states: int = 10, + spib_beta: float = 1e-3, **_: Any, ): self.type = space_mode @@ -48,12 +52,19 @@ def __init__( self.decoder_hidden_dims = list(decoder_hidden_dims or [128, 256]) self.dropout_rate = float(dropout_rate) self.deep_tica_hidden_dims = list(deep_tica_hidden_dims or [256, 128]) + self.spib_n_states = int(spib_n_states) + self.spib_beta = float(spib_beta) self.scaler = TrajectoryScaler("minmax") self.model = None - self.fited = None # PyTorch model for projection + self.fitted = None # PyTorch model used for projection self.device = 'cuda' if torch.cuda.is_available() else 'cpu' def __setstate__(self, state: dict) -> None: + state = dict(state) + # Backwards compatibility: pre-2.x checkpoints stored the projection + # network under the misspelled attribute ``fited``. + if "fited" in state and "fitted" not in state: + state["fitted"] = state.pop("fited") self.__dict__.update(state) self.ensure_config_defaults() @@ -87,6 +98,12 @@ def fit(self, features: np.ndarray, walker_length: int, n_walkers: int): self.ensure_config_defaults() input_size = features.shape[-1] + # Fail fast with an actionable message if an optional backend is missing. + from .registry import ensure_available, is_adaptive_space + + if is_adaptive_space(self.type): + ensure_available(self.type) + # We need continuous trajectories for deeptime TVAE, so we split them by walker # features array should be ordered by walker, then time @@ -127,8 +144,8 @@ def fit(self, features: np.ndarray, walker_length: int, n_walkers: int): self.model.fit(loader_train, n_epochs=self.epochs) - fited = self.model.fetch_model().copy() - self.fited = fited.encoder.eval().to('cpu') + fitted = self.model.fetch_model().copy() + self.fitted = fitted.encoder.eval().to('cpu') elif self.type == "tica": from deeptime.decomposition import TICA @@ -191,18 +208,96 @@ def fit(self, features: np.ndarray, walker_length: int, n_walkers: int): warnings.simplefilter("ignore") trainer.fit(self.model, datamodule) + elif self.type == "vampnet": + from deeptime.decomposition.deep import VAMPNet + from deeptime.util.torch import MLP + + scaled_features = self._torch_features(scaled_features) + traj_list = [ + scaled_features[i * walker_length : (i + 1) * walker_length] + for i in range(n_walkers) + ] + lobe = MLP( + units=[input_size, *self.encoder_hidden_dims, self.latent_dim], + nonlinearity=torch.nn.SiLU, + ).to(self.device) + vampnet = VAMPNet( + lobe=lobe, learning_rate=self.learning_rate, device=self.device + ) + dataset = TrajectoryDataset.from_trajectories(self.lagtime, traj_list) + loader_train = DataLoader( + dataset, + batch_size=self._batch_size(n_walkers, walker_length), + shuffle=True, + ) + self.model = vampnet.fit(loader_train, n_epochs=self.epochs).fetch_model() + self.fitted = lobe.eval().to("cpu") + + elif self.type == "spib": + from .spib import train_spib + + scaled_features = self._torch_features(scaled_features) + traj_list = [ + scaled_features[i * walker_length : (i + 1) * walker_length] + for i in range(n_walkers) + ] + self.fitted = train_spib( + traj_list, + lagtime=self.lagtime, + latent_dim=self.latent_dim, + hidden_dims=self.encoder_hidden_dims, + epochs=self.epochs, + learning_rate=self.learning_rate, + batch_size=self._batch_size(n_walkers, walker_length), + n_states=self.spib_n_states, + beta=self.spib_beta, + dropout=self.dropout_rate, + device=self.device, + ) + + elif self.type == "deep-lda": + # Deep-LDA is supervised: it requires per-frame state labels, so it + # is intended for the targeted/labelled workflow (e.g. known + # reactant/product basins) rather than fully autonomous exploration. + raise NotImplementedError( + "space_mode 'deep-lda' is supervised and needs per-frame state " + "labels; it is registered for the labelled/targeted workflow but " + "not wired into autonomous exploration. Use 'deep-tica', " + "'vampnet', 'spib' or 'tvae' for unsupervised adaptive sampling." + ) + + else: + raise ValueError( + f"space_mode {self.type!r} has no training implementation." + ) + def project(self, features: np.ndarray) -> np.ndarray: """Project scaled features into latent space.""" self.ensure_config_defaults() scaled = self.scaler.transform(features) if self.type == "tvae": - device = next(self.fited.parameters()).device + device = next(self.fitted.parameters()).device + tensor = torch.as_tensor( + self._torch_features(scaled), dtype=torch.float32, device=device + ) + with torch.no_grad(): + projected = self.fitted(tensor)[0].detach().cpu().numpy() + elif self.type == "vampnet": + device = next(self.fitted.parameters()).device + tensor = torch.as_tensor( + self._torch_features(scaled), dtype=torch.float32, device=device + ) + with torch.no_grad(): + projected = self.fitted(tensor).detach().cpu().numpy() + elif self.type == "spib": + device = next(self.fitted.parameters()).device tensor = torch.as_tensor( self._torch_features(scaled), dtype=torch.float32, device=device ) with torch.no_grad(): - projected = self.fited(tensor)[0].detach().cpu().numpy() + mean, _ = self.fitted(tensor) + projected = mean.detach().cpu().numpy() elif self.type == "deep-tica": tensor = torch.as_tensor( self._torch_features(scaled), dtype=torch.float32 diff --git a/autosampler/spaces/registry.py b/autosampler/spaces/registry.py new file mode 100644 index 0000000..5a171b8 --- /dev/null +++ b/autosampler/spaces/registry.py @@ -0,0 +1,141 @@ +"""Registry of collective-variable (CV) / dimensionality-reduction methods. + +This is the single source of truth for which adaptive CV methods AutoSampler +supports, what backend each needs, and whether each is available in the current +environment. It lets new cutting-edge CV methods be added in one place and keeps +the rest of the codebase (e.g. ``core.py``) free of hard-coded method lists. + +Supported methods +----------------- +- ``pca`` : linear PCA baseline (scikit-learn). +- ``tica`` : time-lagged independent component analysis (deeptime). +- ``tvae`` : time-lagged variational autoencoder (deeptime + torch). +- ``vampnet`` : VAMPNet deep CV via the variational approach for Markov + processes (deeptime + torch). +- ``spib`` : State Predictive Information Bottleneck, Wang & Tiwary 2021 + (built-in torch implementation, no extra dependency). +- ``deep-tica`` : deep (nonlinear) TICA via mlcolvar (optional). +- ``deep-lda`` : deep linear discriminant analysis, supervised, via mlcolvar + (optional; requires state labels). + +``space_mode: fixed`` (user-provided CVs through a project file) is handled +outside this registry. +""" + +from __future__ import annotations + +import importlib.util +from dataclasses import dataclass + +FIXED_MODE = "fixed" + + +@dataclass(frozen=True) +class CVMethod: + """Metadata describing one CV method.""" + + name: str + backend: str # 'sklearn' | 'deeptime' | 'mlcolvar' | 'builtin' + time_lagged: bool # uses a lag time (dynamics-aware) + supervised: bool # requires per-frame state labels + requires: tuple[str, ...] # importable module names needed at runtime + description: str + optional: bool = False # needs an optional / extra dependency + + +_METHODS: dict[str, CVMethod] = { + "pca": CVMethod( + "pca", "sklearn", False, False, ("sklearn",), + "Linear PCA baseline.", + ), + "tica": CVMethod( + "tica", "deeptime", True, False, ("deeptime",), + "Time-lagged independent component analysis (linear, dynamics-aware).", + ), + "tvae": CVMethod( + "tvae", "deeptime", True, False, ("deeptime", "torch"), + "Time-lagged variational autoencoder (nonlinear bottleneck).", + ), + "vampnet": CVMethod( + "vampnet", "deeptime", True, False, ("deeptime", "torch"), + "VAMPNet: deep CVs trained with the variational approach for Markov " + "processes (VAMP-2 score).", + ), + "spib": CVMethod( + "spib", "builtin", True, False, ("torch",), + "State Predictive Information Bottleneck (Wang & Tiwary, 2021): a " + "variational information-bottleneck CV that predicts the future state.", + ), + "deep-tica": CVMethod( + "deep-tica", "mlcolvar", True, False, ("mlcolvar", "lightning", "torch"), + "Deep (nonlinear) TICA via mlcolvar.", True, + ), + "deep-lda": CVMethod( + "deep-lda", "mlcolvar", False, True, ("mlcolvar", "lightning", "torch"), + "Deep linear discriminant analysis (supervised) via mlcolvar; needs " + "per-frame state labels.", True, + ), +} + +# Install hints for optional backends, surfaced when a method is unavailable. +_INSTALL_HINTS = { + "mlcolvar": 'pip install "autosampler[deep-tica]" # installs mlcolvar + lightning', + "lightning": 'pip install "autosampler[deep-tica]"', + "deeptime": "pip install deeptime", + "torch": "pip install torch", + "sklearn": "pip install scikit-learn", +} + + +def all_methods() -> dict[str, CVMethod]: + """Return a copy of the full method registry.""" + return dict(_METHODS) + + +def adaptive_modes() -> tuple[str, ...]: + """Names of all adaptive (learned) CV methods.""" + return tuple(_METHODS) + + +def is_adaptive_space(mode: str) -> bool: + """True if ``mode`` is a learned CV method (i.e. not ``fixed``).""" + return mode in _METHODS + + +def get_method(mode: str) -> CVMethod: + if mode not in _METHODS: + raise ValueError( + f"Unknown CV space_mode {mode!r}. Valid options: " + f"{(FIXED_MODE,) + adaptive_modes()}." + ) + return _METHODS[mode] + + +def _module_available(name: str) -> bool: + try: + return importlib.util.find_spec(name) is not None + except (ImportError, ValueError): # pragma: no cover - defensive + return False + + +def is_available(mode: str) -> bool: + """True if every backend dependency of ``mode`` is importable.""" + method = get_method(mode) + return all(_module_available(req) for req in method.requires) + + +def ensure_available(mode: str) -> None: + """Raise an informative ImportError if ``mode``'s backend is missing.""" + method = get_method(mode) + missing = [req for req in method.requires if not _module_available(req)] + if missing: + hints = "; ".join(_INSTALL_HINTS.get(m, f"pip install {m}") for m in missing) + raise ImportError( + f"CV method {mode!r} requires missing package(s): {', '.join(missing)}. " + f"Install via: {hints}." + ) + + +def register_method(method: CVMethod) -> None: + """Register a custom CV method (extension point for plugins).""" + _METHODS[method.name] = method diff --git a/autosampler/spaces/retraining.py b/autosampler/spaces/retraining.py new file mode 100644 index 0000000..94ae210 --- /dev/null +++ b/autosampler/spaces/retraining.py @@ -0,0 +1,118 @@ +"""Adaptive CV-retraining policy. + +A learned CV can go stale as new regions of phase space are discovered. Instead +of always retraining on a fixed schedule, the ``vamp_adaptive`` policy retrains +only when the current CV's VAMP-2 score on fresh data drops relative to its +post-training reference — i.e. when the CV stops resolving the dynamics it is +now seeing. This couples retraining to sampling progress and avoids both +under- and over-training. + +The controller is pure decision logic (no MD/torch), so it is fully unit-tested. +""" + +from __future__ import annotations + + +class RetrainController: + """Decide whether to (re)train the CV model this iteration. + + Parameters + ---------- + policy: + ``"fixed"`` reproduces the legacy ``iteration % retrain_freq == 0`` + schedule. ``"vamp_adaptive"`` retrains when the CV's VAMP-2 score drops + by more than ``vamp_tol`` (relative) below its reference. + retrain_freq: + Cadence for the ``fixed`` policy. + vamp_tol: + Relative VAMP-2 drop that triggers a retrain (``vamp_adaptive``). + min_interval / max_interval: + Lower/upper bounds (in iterations) between retrains for the adaptive + policy, to avoid thrashing and to guarantee periodic refreshes. + """ + + def __init__( + self, + policy: str = "fixed", + retrain_freq: int = 1, + vamp_tol: float = 0.1, + min_interval: int = 1, + max_interval: int | None = None, + ): + if policy not in {"fixed", "vamp_adaptive"}: + raise ValueError("policy must be 'fixed' or 'vamp_adaptive'") + self.policy = policy + self.retrain_freq = int(retrain_freq) + self.vamp_tol = float(vamp_tol) + self.min_interval = int(min_interval) + self.max_interval = None if max_interval is None else int(max_interval) + self.reference_score: float | None = None + self.iters_since_retrain: int = 0 + self.last_reason: str | None = None + + def should_retrain( + self, + iteration: int, + has_model: bool, + current_score: float | None = None, + ) -> bool: + """Return whether to retrain at ``iteration``. + + ``current_score`` is the VAMP-2 score of the *existing* CV on the current + data (ignored when there is no model yet or for the ``fixed`` policy). + """ + if not has_model: + self.last_reason = "no model yet" + return True + + if self.policy == "fixed": + due = self.retrain_freq > 0 and iteration % self.retrain_freq == 0 + self.last_reason = "scheduled" if due else None + return due + + # vamp_adaptive + if self.iters_since_retrain < self.min_interval: + self.last_reason = None + return False + if self.max_interval is not None and self.iters_since_retrain >= self.max_interval: + self.last_reason = f"max interval ({self.max_interval}) reached" + return True + if self.reference_score is None or current_score is None: + self.last_reason = None + return False + rel_drop = (self.reference_score - current_score) / max( + abs(self.reference_score), 1e-9 + ) + if rel_drop > self.vamp_tol: + self.last_reason = ( + f"VAMP-2 dropped {rel_drop:.1%} (>{self.vamp_tol:.1%}) " + f"from {self.reference_score:.3f} to {current_score:.3f}" + ) + return True + self.last_reason = None + return False + + def notify_retrained(self, new_score: float | None = None) -> None: + """Record that a retrain happened, updating the reference VAMP-2 score.""" + self.iters_since_retrain = 0 + if new_score is not None: + self.reference_score = ( + new_score + if self.reference_score is None + else max(self.reference_score, new_score) + ) + + def notify_skipped(self) -> None: + self.iters_since_retrain += 1 + + def state_dict(self) -> dict: + return { + "reference_score": self.reference_score, + "iters_since_retrain": self.iters_since_retrain, + } + + def load_state_dict(self, state: dict) -> None: + if not state: + return + self.reference_score = state.get("reference_score") + self.iters_since_retrain = int(state.get("iters_since_retrain", 0)) diff --git a/autosampler/spaces/spib.py b/autosampler/spaces/spib.py new file mode 100644 index 0000000..d380f6d --- /dev/null +++ b/autosampler/spaces/spib.py @@ -0,0 +1,179 @@ +"""State Predictive Information Bottleneck (SPIB) collective variable. + +A compact, self-contained PyTorch implementation of SPIB (Wang & Tiwary, +*Nat. Commun.* 2021). SPIB learns a low-dimensional CV ``z`` that retains just +enough information about the present configuration to predict the *future* +state (a time-lagged, discretised label) under a variational information +bottleneck: + + L = E[ CE(p(state_{t+tau} | z), label_{t+tau}) ] + beta * KL(q(z|x) || prior) + +The encoder mean becomes the CV used for projection. Only ``torch`` is required +(no extra dependency), so SPIB is available out of the box. + +This module exposes the network pieces plus :func:`train_spib`, which the +:class:`~autosampler.spaces.model.AdaptiveSpaceModel` calls. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np +import torch +from torch import nn + + +def _make_mlp(input_size: int, hidden_dims: Sequence[int], dropout: float) -> nn.Sequential: + layers: list[nn.Module] = [] + prev = input_size + for width in hidden_dims: + layers += [nn.Linear(prev, width), nn.SiLU()] + if dropout > 0: + layers.append(nn.Dropout(dropout)) + prev = width + return nn.Sequential(*layers) + + +class SPIBEncoder(nn.Module): + """Encode features into a Gaussian latent (mean, log-variance).""" + + def __init__( + self, + input_size: int, + latent_dim: int, + hidden_dims: Sequence[int], + dropout: float = 0.0, + ) -> None: + super().__init__() + self.backbone = _make_mlp(input_size, hidden_dims, dropout) + last = hidden_dims[-1] if hidden_dims else input_size + self.mean = nn.Linear(last, latent_dim) + self.log_var = nn.Linear(last, latent_dim) + + def forward(self, x: torch.Tensor): + h = self.backbone(x) + return self.mean(h), self.log_var(h) + + +class SPIBPredictor(nn.Module): + """Predict the future-state distribution from the latent CV.""" + + def __init__(self, latent_dim: int, n_states: int, hidden: int = 64) -> None: + super().__init__() + self.net = nn.Sequential( + nn.Linear(latent_dim, hidden), + nn.SiLU(), + nn.Linear(hidden, n_states), + ) + + def forward(self, z: torch.Tensor) -> torch.Tensor: + return self.net(z) + + +def _reparameterise(mean: torch.Tensor, log_var: torch.Tensor) -> torch.Tensor: + std = torch.exp(0.5 * log_var) + return mean + std * torch.randn_like(std) + + +def _kl_to_standard_normal(mean: torch.Tensor, log_var: torch.Tensor) -> torch.Tensor: + return -0.5 * torch.mean(torch.sum(1 + log_var - mean.pow(2) - log_var.exp(), dim=1)) + + +def _state_labels(features: np.ndarray, n_states: int, seed: int) -> np.ndarray: + """Initial discrete states via k-means (deeptime, then sklearn fallback).""" + n_states = max(2, min(n_states, len(features))) + try: + from deeptime.clustering import KMeans + + model = KMeans( + n_clusters=n_states, max_iter=100, fixed_seed=seed, progress=None + ).fit_fetch(features) + return np.asarray(model.transform(features), dtype=np.int64) + except Exception: # noqa: BLE001 + from sklearn.cluster import KMeans as SKMeans + + model = SKMeans(n_clusters=n_states, n_init=10, random_state=seed) + return np.asarray(model.fit_predict(features), dtype=np.int64) + + +def train_spib( + traj_list: Sequence[np.ndarray], + lagtime: int, + latent_dim: int, + hidden_dims: Sequence[int], + epochs: int, + learning_rate: float, + batch_size: int, + n_states: int = 10, + beta: float = 1e-3, + dropout: float = 0.0, + device: str | None = None, + seed: int = 42, +) -> SPIBEncoder: + """Train SPIB and return the encoder (CPU, eval mode) for projection. + + Parameters + ---------- + traj_list: + List of continuous per-walker feature arrays ``(n_frames_i, n_features)`` + (already scaled). Time-lagged pairs are formed within each trajectory. + lagtime: + Prediction lag (in frames). + n_states: + Number of discretised states the bottleneck predicts. + beta: + Information-bottleneck weight on the KL term. + """ + torch.manual_seed(seed) + device = device or ("cuda" if torch.cuda.is_available() else "cpu") + + stacked = np.vstack([np.asarray(t, dtype=np.float32) for t in traj_list]) + input_size = stacked.shape[1] + labels_all = _state_labels(stacked, n_states, seed) + n_states_eff = int(labels_all.max()) + 1 + + # Build time-lagged (x_t, label_{t+lag}) pairs within each trajectory. + offset = 0 + xs: list[np.ndarray] = [] + ys: list[np.ndarray] = [] + for traj in traj_list: + length = len(traj) + if length > lagtime: + traj_labels = labels_all[offset : offset + length] + xs.append(np.asarray(traj[:-lagtime], dtype=np.float32)) + ys.append(traj_labels[lagtime:]) + offset += length + if not xs: + raise ValueError("SPIB: no trajectory longer than the lag time.") + + x = torch.from_numpy(np.vstack(xs)).to(device) + y = torch.from_numpy(np.concatenate(ys)).long().to(device) + + encoder = SPIBEncoder(input_size, latent_dim, hidden_dims, dropout).to(device) + predictor = SPIBPredictor(latent_dim, n_states_eff).to(device) + optim = torch.optim.Adam( + list(encoder.parameters()) + list(predictor.parameters()), lr=learning_rate + ) + ce = nn.CrossEntropyLoss() + + n = x.shape[0] + batch_size = max(1, min(batch_size, n)) + generator = torch.Generator(device="cpu").manual_seed(seed) + encoder.train() + predictor.train() + for _ in range(epochs): + perm = torch.randperm(n, generator=generator).to(device) + for start in range(0, n, batch_size): + idx = perm[start : start + batch_size] + xb, yb = x[idx], y[idx] + mean, log_var = encoder(xb) + z = _reparameterise(mean, log_var) + logits = predictor(z) + loss = ce(logits, yb) + beta * _kl_to_standard_normal(mean, log_var) + optim.zero_grad() + loss.backward() + optim.step() + + encoder.eval() + return encoder.to("cpu") diff --git a/autosampler/spawners/__init__.py b/autosampler/spawners/__init__.py index 6588f78..7668c13 100644 --- a/autosampler/spawners/__init__.py +++ b/autosampler/spawners/__init__.py @@ -2,4 +2,6 @@ from .fps import FPSSpawner from .density import DensitySpawner from .lof import LOFSpawner +from .msm import MSMSpawner from .voronoi import VoronoiSpawner +from .we import WESpawner diff --git a/autosampler/spawners/density.py b/autosampler/spawners/density.py index 0dd79ff..56c4bba 100644 --- a/autosampler/spawners/density.py +++ b/autosampler/spawners/density.py @@ -30,17 +30,23 @@ def __init__( self.probabilistic = probabilistic self.target = target self.recent_bins: deque[set[Any]] = deque(maxlen=recent_window) + # Optional landscape-adaptive binner (set by the orchestrator); None -> grid. + self.binner = None def sample(self, points: np.ndarray, top_n: int, history: dict[int, Any] | None = None) -> list[int]: points = np.asarray(points, dtype=float) cumulative_points = _cumulative_points(points, history) - binner = RegularBinner( - n_bins=self.n_bins, - min_values=self.min_values, - max_values=self.max_values, - target=self.target if self.mode == "target" else None, - ) - table = binner.fit(cumulative_points) + if self.binner is not None: + # Keep the adaptive binner in sync with resolution bumps. + self.binner.n_bins = np.asarray(self.n_bins, dtype=int) + table = self.binner.fit(cumulative_points) + else: + table = RegularBinner( + n_bins=self.n_bins, + min_values=self.min_values, + max_values=self.max_values, + target=self.target if self.mode == "target" else None, + ).fit(cumulative_points) selected_rows = ( self._probabilistic_rows(table, top_n) if self.probabilistic diff --git a/autosampler/spawners/msm.py b/autosampler/spawners/msm.py new file mode 100644 index 0000000..32daf8a --- /dev/null +++ b/autosampler/spawners/msm.py @@ -0,0 +1,207 @@ +"""MSM-guided / least-counts spawner. + +Selects restart frames that most reduce the statistical error of the Markov State +Model. Two modes: + +* **MSM-guided** (when the orchestrator supplies the latest ``MSMResult`` and the + estimator's clustering): score each microstate by + ``π_i · |ψ_i| · (σ_out,i / mean) + α/√c_i`` — its stationary flux × leverage on + the slow processes (slow-eigenvector amplitude) × outflow statistical + uncertainty (Dirichlet σ of the transition row), plus a least-counts/frontier + exploration term. New / disconnected microstates get the exploration weight so + they get connected. This throws runs at the transitions whose in/out rates are + both uncertain and important. +* **Least-counts fallback** (no MSM yet — iteration 0 / after resume): the classic + inverse-count weighting on an independent clustering. + +Implements the standard ``sample(points, top_n, history)`` contract, returning +indices into the cumulative point cloud, so it is a drop-in ``spawn_scheme: msm``. +With ``alpha`` large or ``uncertainty=False`` and uniform leverage the MSM-guided +score reduces to least-counts (backward-compatible default behaviour). +""" + +from __future__ import annotations + +import numpy as np + +from .base import Spawner, SpawnerFactory +from .density import _cumulative_points + + +class MSMSpawner(Spawner): + """Microstate spawner targeting MSM statistical convergence. + + Parameters + ---------- + n_clusters: + Microstate count for the least-counts fallback clustering. + mode / target: + ``"explore"`` (default) or ``"target"`` (bias toward ``target``). + weighting: + Fallback least-counts weighting (``"least_counts"`` or ``"sqrt"``). + alpha: + Weight of the exploration / least-counts term in the MSM-guided score. + leverage: + Number of slow eigenvectors used for the leverage factor (0 → uniform). + uncertainty: + Include the outflow-uncertainty factor (``True``) or not. + seed: + RNG / clustering seed. + """ + + def __init__( + self, + n_clusters: int = 150, + mode: str = "explore", + target: list | None = None, + weighting: str = "least_counts", + alpha: float = 1.0, + leverage: int = 1, + uncertainty: bool = True, + seed: int = 42, + **_, + ): + self.n_clusters = int(n_clusters) + self.mode = mode + self.target = np.asarray(target, dtype=float) if target is not None else None + self.weighting = weighting + self.alpha = float(alpha) + self.leverage = int(leverage) + self.uncertainty = bool(uncertainty) + self.seed = int(seed) + # Set by the orchestrator each iteration (previous iteration's MSM and the + # estimator's clustering); None → least-counts fallback. + self.msm_result = None + self.cluster_model = None + + # ------------------------------------------------------------------ # + def sample(self, points: np.ndarray, top_n: int, history=None) -> list: + points = np.asarray(points, dtype=float) + if len(points) == 0: + raise ValueError("Cannot sample MSM points from an empty point cloud.") + cumulative = _cumulative_points(points, history) + if cumulative.ndim == 1: + cumulative = cumulative.reshape(-1, 1) + if len(cumulative) == 1: + return [0 for _ in range(top_n)] + + rng = np.random.default_rng(self.seed) + frame_weights = self._msm_guided_weights(cumulative) + if frame_weights is None: + frame_weights = self._least_counts_weights(cumulative) + + if self.mode == "target" and self.target is not None: + dists = np.linalg.norm(cumulative - self.target, axis=1) + frame_weights = frame_weights / (dists + 1e-10) + + total = frame_weights.sum() + if total <= 0 or not np.isfinite(total): + frame_weights = np.ones(len(cumulative)) / len(cumulative) + else: + frame_weights = frame_weights / total + + n_cumulative = len(cumulative) + n_nonzero = int(np.count_nonzero(frame_weights)) + replace = n_cumulative < top_n or n_nonzero < top_n + return ( + rng.choice( + np.arange(n_cumulative), size=top_n, replace=replace, p=frame_weights + ) + .astype(int) + .tolist() + ) + + # ------------------------------------------------------------------ # + def _msm_guided_weights(self, cumulative: np.ndarray) -> np.ndarray | None: + """Per-frame weights from uncertainty × leverage × flux, or None to fall back.""" + res = self.msm_result + model = self.cluster_model + if res is None or model is None: + return None + T = getattr(res, "transition_matrix", None) + pi = getattr(res, "stationary_distribution", None) + counts = getattr(res, "counts_per_state", None) + symbols = getattr(res, "state_symbols", None) + if T is None or pi is None or counts is None or symbols is None: + return None + try: + micro = np.asarray(model.transform(cumulative), dtype=int) + except Exception: # noqa: BLE001 - clustering mismatch → fall back + return None + + T = np.asarray(T, dtype=float) + pi = np.asarray(pi, dtype=float) + counts = np.asarray(counts, dtype=float) + symbols = np.asarray(symbols, dtype=int) + n_active = len(pi) + if n_active == 0 or len(counts) != n_active: + return None + + # Outflow uncertainty per active state (Dirichlet row variance). + var = T * (1.0 - T) / (counts[:, None] + 1.0) + s_out = np.sqrt(np.clip(var.sum(axis=1), 0.0, None)) + mean_s = s_out[s_out > 0].mean() if np.any(s_out > 0) else 1.0 + s_term = (s_out / mean_s) if self.uncertainty else np.ones(n_active) + + # Leverage = summed |slow right-eigenvector| amplitude. + E = getattr(res, "eigenvectors", None) + if E is not None and self.leverage > 0: + E = np.asarray(E, dtype=float) + lev = np.abs(E[:, : self.leverage]).sum(axis=1) + else: + lev = np.ones(n_active) + + score_active = pi * lev * s_term + micro_base = score_active + self.alpha / np.sqrt(counts + 1.0) # per active state + + symbol_to_active = {int(s): i for i, s in enumerate(symbols)} + active_idx = np.array( + [symbol_to_active.get(int(m), -1) for m in micro], dtype=int + ) + is_active = active_idx >= 0 + + # Microstate-level base weight: active states use their score; frontier + # (new / disconnected) microstates get a flat exploration weight. + base = np.full(len(cumulative), self.alpha, dtype=float) + if np.any(is_active): + base[is_active] = micro_base[active_idx[is_active]] + + # Distribute the microstate weight over its frames in the cumulative cloud. + _, inverse, sizes = np.unique(micro, return_inverse=True, return_counts=True) + size_per_frame = sizes[inverse].astype(float) + return base / np.maximum(size_per_frame, 1.0) + + # ------------------------------------------------------------------ # + def _least_counts_weights(self, cumulative: np.ndarray) -> np.ndarray: + assignments, n_states = self._cluster(cumulative) + counts = np.bincount(assignments, minlength=n_states).astype(float) + microstate_counts = np.maximum(counts[assignments], 1.0) + if self.weighting == "sqrt": + microstate_weight = 1.0 / np.sqrt(microstate_counts) + else: + microstate_weight = 1.0 / microstate_counts + return microstate_weight / microstate_counts + + def _cluster(self, cumulative: np.ndarray): + n_states = min(self.n_clusters, len(cumulative)) + try: + from deeptime.clustering import KMeans + + model = KMeans( + n_clusters=n_states, max_iter=100, fixed_seed=self.seed, progress=None + ).fit_fetch(cumulative) + assignments = np.asarray(model.transform(cumulative), dtype=int) + return assignments, n_states + except Exception: # noqa: BLE001 - fall back to scikit-learn / numpy + pass + try: + from sklearn.cluster import KMeans as SKMeans + + model = SKMeans(n_clusters=n_states, n_init=10, random_state=self.seed) + assignments = model.fit_predict(cumulative) + return np.asarray(assignments, dtype=int), n_states + except Exception: # noqa: BLE001 - last-resort single bucket + return np.zeros(len(cumulative), dtype=int), 1 + + +SpawnerFactory.register("msm", MSMSpawner) diff --git a/autosampler/spawners/we.py b/autosampler/spawners/we.py new file mode 100644 index 0000000..e95dfed --- /dev/null +++ b/autosampler/spawners/we.py @@ -0,0 +1,119 @@ +"""Weighted-ensemble spawner. + +Carries statistical weights on the cumulative point cloud and uses +:class:`~autosampler.binning.we.WeightedEnsemble` split/merge resampling to pick +the next walkers, conserving total weight. A faithful alternative to MSM +least-counts / density spawning when unbiased weights are wanted. + +Implements the standard ``sample(points, top_n, history)`` contract, returning +indices into the cumulative point cloud (repeats indicate split walkers), so it +is a drop-in ``spawn_scheme: we`` option. Per-frame weights are carried across +iterations in the spawner instance (and exposed via ``state_dict``). +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from autosampler.binning.spatial import RegularBinner +from autosampler.binning.we import WeightedEnsemble + +from .base import Spawner, SpawnerFactory +from .density import _cumulative_points + + +class WESpawner(Spawner): + def __init__( + self, + n_bins: list[int] | None = None, + min_values: list[float] | None = None, + max_values: list[float] | None = None, + target_per_bin: int = 4, + seed: int = 42, + **_: Any, + ): + self.n_bins = n_bins or [30, 30] + self.min_values = min_values + self.max_values = max_values + self.we = WeightedEnsemble(target_per_bin=target_per_bin) + self.seed = int(seed) + self.weights: np.ndarray | None = None # aligned to cumulative cloud + # Optional landscape-adaptive binner (set by the orchestrator); None -> grid. + self.binner = None + + def sample( + self, points: np.ndarray, top_n: int, history: dict[int, Any] | None = None + ) -> list[int]: + points = np.asarray(points, dtype=float) + if len(points) == 0: + raise ValueError("Cannot run WE on an empty point cloud.") + cumulative = _cumulative_points(points, history) + n = len(cumulative) + + # Initialise / extend per-frame weights; new frames enter with the mean + # weight so they neither dominate nor vanish, then renormalise to 1. + weights = self._extend_weights(n) + + labels = self._bin_labels(cumulative) + rng = np.random.default_rng(self.seed) + result = self.we.resample(weights, labels, rng=rng) + + # Carry weights forward: aggregate resampled weight onto parent frames. + new_weights = np.zeros(n, dtype=float) + for parent, w in zip(result.parents, result.weights, strict=False): + new_weights[parent] += w + total = new_weights.sum() + self.weights = new_weights / total if total > 0 else None + + return self._draw(result, top_n, rng) + + def _extend_weights(self, n: int) -> np.ndarray: + if self.weights is None or len(self.weights) == 0: + weights = np.full(n, 1.0 / n, dtype=float) + elif len(self.weights) < n: + fill = float(np.mean(self.weights)) if len(self.weights) else 1.0 + weights = np.concatenate( + [self.weights, np.full(n - len(self.weights), fill)] + ) + else: + weights = np.asarray(self.weights[:n], dtype=float) + total = weights.sum() + return weights / total if total > 0 else np.full(n, 1.0 / n) + + def _bin_labels(self, cumulative: np.ndarray) -> np.ndarray: + if self.binner is not None: + self.binner.n_bins = np.asarray(self.n_bins, dtype=int) + table = self.binner.fit(cumulative) + else: + table = RegularBinner( + n_bins=self.n_bins, + min_values=self.min_values, + max_values=self.max_values, + ).fit(cumulative) + labels = np.full(len(cumulative), -1, dtype=int) + for row, frames in enumerate(table.populated_data): + for frame in frames: + labels[frame] = row + return labels + + @staticmethod + def _draw(result, top_n: int, rng: np.random.Generator) -> list[int]: + parents = np.asarray(result.parents, dtype=int) + weights = np.asarray(result.weights, dtype=float) + total = weights.sum() + probs = weights / total if total > 0 else np.full(len(parents), 1.0 / len(parents)) + replace = len(parents) < top_n + chosen = rng.choice(parents, size=top_n, replace=replace, p=probs) + return [int(i) for i in chosen] + + def state_dict(self) -> dict: + return {"weights": None if self.weights is None else self.weights.tolist()} + + def load_state_dict(self, state: dict) -> None: + if state and state.get("weights") is not None: + self.weights = np.asarray(state["weights"], dtype=float) + + +SpawnerFactory.register("we", WESpawner) diff --git a/autosampler/templates.py b/autosampler/templates.py new file mode 100644 index 0000000..33472bc --- /dev/null +++ b/autosampler/templates.py @@ -0,0 +1,157 @@ +"""Canonical, fully-annotated AutoSampler input file. + +``DEFAULT_TEMPLATE`` is the single source of truth for the starter config emitted +by ``autosampler-init`` and mirrored to ``examples/template.yaml``. It documents +every section, the available method choices, and sensible defaults. +""" + +from __future__ import annotations + +DEFAULT_TEMPLATE = """\ +# ============================================================================ +# AutoSampler input file +# +# A single YAML file fully describes a run: the system, MD engine, how walkers +# are spawned, the collective-variable (CV) space, optional feature selection +# and MSM-convergence, and where jobs execute. Paths are resolved relative to +# this file. Validate before running: autosampler --config config.yaml --check +# Full reference: docs/input_file.md and docs/configuration.md +# ============================================================================ + +# ---- System: structure, topology, and how features are read ---------------- +system: + conf_file: start.gro # coordinates (.gro/.pdb/.crd/...) + top_file: topol.top # topology + topology: gromacs # gromacs | amber | charmm + # system_file: system.py # optional: custom OpenMM System builder + # project_file: project.py # required for space_mode: fixed (defines extract_cvs) + trajectory_topology_file: start.gro + feature_selection: "protein and not (type H)" # MDAnalysis atom selection + +# ---- Engine: the MD backend and thermodynamic settings --------------------- +engine: + md_engine: openmm # openmm | gromacs | amber + platform_name: CUDA # use CPU if OpenMM has no registered CUDA platform + precision: mixed # mixed | single | double + temperature: 300.0 # Kelvin + pressure: 1.0 # bar + dt: 0.002 # ps + npt: false # constant-pressure ensemble + equilibrate: false + # gpu_ids: [0, 1] # explicit GPUs for the local backend + # --- GROMACS-only --- + # gromacs_executable: gmx + # gromacs_include_dir: /path/to/gromacs/top + # --- Amber-only --- + # amber_executable: pmemd.cuda + # amber_input_file: prod.in + +# ---- Spawning: how the next walkers are chosen ----------------------------- +spawning: + spawn_scheme: density # density | voronoi | lof | fps | msm | we + spawn_type: hard # hard | probabilistic + search_mode: explore # explore | target + walker: 16 # walkers per iteration + step: 5000 # MD steps per walker + stride: 50 # save a frame every N steps + max_workers: 4 # concurrent walkers (local backend) + # target: [1.5, -1.2] # CV target when search_mode: target + voronoi_clusters: 150 # cells / microstates (voronoi & msm spawners) + we_target_per_bin: 4 # walkers per bin for spawn_scheme: we + lof_neighbors: 20 + # Coverage-based (legacy) convergence; superseded by msm.* when msm.enabled: + resolution_check_patience: 5 + convergence_patience: 0 + +# ---- CV space: fixed physical CVs or a learned latent space ---------------- +# space_mode: fixed | pca | tica | tvae | vampnet | spib | deep-tica | deep-lda +space_mode: vampnet +adaptive_feature_type: distances # distances | fitted_coords | phi_psi +retrain_freq: 5 # retrain cadence for retrain_policy: fixed +retrain_policy: fixed # fixed | vamp_adaptive (retrain on VAMP-2 drop) +# vamp_retrain_tol: 0.1 # relative VAMP-2 drop that triggers a retrain +# retrain_min_interval: 1 +# retrain_max_interval: 20 +aggregate_memory: true +max_adaptive_memory_frames: 50000 + +adaptive_model: # hyperparameters for learned CVs + lagtime: 5 + latent_dim: 2 + epochs: 50 + learning_rate: 0.0005 + encoder_hidden_dims: [64, 32] + decoder_hidden_dims: [32, 64] + dropout_rate: 0.1 + deep_tica_hidden_dims: [64, 32] + spib_n_states: 10 # SPIB only + spib_beta: 0.001 # SPIB only + +# ---- Feature selection: VAMP-2 optimisation of the input features ---------- +feature_selection: + enabled: false # opt-in + method: greedy_vamp # greedy_vamp | all + lagtime: 10 + cadence: 5 # re-select every N iterations + # max_features: 50 + # min_gain: 1.0e-4 + # candidate_feature_types: [distances, fitted_coords] # rank types by VAMP-2 + +# ---- MSM: build a Markov State Model and stop on convergence --------------- +msm: + enabled: false # opt-in; stops sampling on convergence + cadence: 1 # estimate the MSM every N iterations + min_frames: 2000 # wait for this many cumulative frames + lagtime: 10 + lagtimes: [1, 2, 5, 10, 20] # implied-timescale sweep (diagnostics) + n_microstates: 100 + cluster_method: kmeans # kmeans | regspace + estimator: mle # mle | bayesian (error bars) + n_timescales: 3 + n_metastable: 4 # PCCA+ coarse-graining + stable_clustering: false # comparable microstate IDs / T_ij across iters + # MSM-guided spawner (spawn_scheme: msm): uncertainty x leverage x flux + spawn_alpha: 1.0 # exploration / least-counts weight + spawn_leverage: 1 # slow eigenvectors used for leverage + spawn_uncertainty: true # include outflow-uncertainty factor + convergence_mode: all # all | any + convergence_patience: 3 + convergence_criteria: + - name: implied_timescales + params: {tol: 0.1, n_timescales: 2} + - name: vamp2 + params: {tol: 0.05} + # - name: transition_matrix # flux-weighted T_ij statistical convergence + # params: {tol: 0.2, min_flux: 1.0e-3} + # - name: statistical_error # needs estimator: bayesian + # params: {tol: 0.2} + +# ---- Binning: landscape-adaptive stratification for density / WE spawners --- +binning: + scheme: uniform # uniform | gradient | mab | eigenvector + # n_fine: 100 # density-histogram resolution (gradient scheme) + # smoothing: 3 # density smoothing window (gradient scheme) + +# ---- Execution: where walkers run ------------------------------------------ +execution: + backend: local # local | slurm | pbs + # --- scheduler settings (slurm/pbs) --- + # partition: gpu + # account: my_alloc + # walltime: "02:00:00" + # cpus_per_task: 8 + # gpus_per_task: 1 + # memory: "16G" + # max_retries: 2 + # module_loads: + # - "module load cuda/12.2" + +# ---- Run-level settings ---------------------------------------------------- +outdir: runs/my_run +random_seed: 42 +checkpoint_freq: 1 +save_features: true +n_bins: [30, 30] # binning for coverage / fixed-space grid +# min_values: [-3.14159, -3.14159] # fixed-space bounds (space_mode: fixed) +# max_values: [3.14159, 3.14159] +""" diff --git a/autosampler/utils/seeds.py b/autosampler/utils/seeds.py index 78ab9ba..a5aaa74 100644 --- a/autosampler/utils/seeds.py +++ b/autosampler/utils/seeds.py @@ -1,26 +1,50 @@ +import os import random + import numpy as np import torch -import os + class SeedManager: - """Manages random number generator seeding across all libraries to ensure bitwise reproducibility.""" - + """Seed every RNG backend AutoSampler touches for reproducible runs. + + Covers Python, NumPy, and PyTorch (CPU + CUDA), and — when available — + PyTorch Lightning (used by deep-TICA/LDA) via ``seed_everything``. cuDNN is + put in deterministic mode. Note that exact bitwise reproducibility across + different hardware/MD engines is not guaranteed (floating-point and + nondeterministic CUDA kernels); seeding makes runs as deterministic as the + backends allow. + """ + def __init__(self, seed: int): self.seed = seed def set_seed(self) -> None: - """Initialize random seeds globally across all relevant backends.""" + """Initialise random seeds globally across all relevant backends.""" # 1. Standard Python library random.seed(self.seed) - os.environ['PYTHONHASHSEED'] = str(self.seed) - + os.environ["PYTHONHASHSEED"] = str(self.seed) + # 2. NumPy backend np.random.seed(self.seed) - + # 3. PyTorch (CPU & GPU) torch.manual_seed(self.seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(self.seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False + + # 4. PyTorch Lightning (deep-TICA / deep-LDA), if installed. + self._seed_lightning() + + def _seed_lightning(self) -> None: + for module in ("lightning.pytorch", "pytorch_lightning", "lightning"): + try: + mod = __import__(module, fromlist=["seed_everything"]) + except ImportError: + continue + seed_everything = getattr(mod, "seed_everything", None) + if callable(seed_everything): + seed_everything(self.seed, workers=True) + return diff --git a/autosampler/workflows/parallel.py b/autosampler/workflows/parallel.py index 123e750..5cd8ec5 100644 --- a/autosampler/workflows/parallel.py +++ b/autosampler/workflows/parallel.py @@ -1,156 +1,47 @@ -from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, wait -from pathlib import Path -from typing import Optional -import os - -from autosampler.engines.amber import amber_trajectory_suffix - -def _worker_task(engine_name: str, engine_kwargs: dict, prepare_kwargs: dict, run_kwargs: dict) -> bool: - """Standalone function to instantiate engine in the worker process and run it.""" - import warnings - warnings.filterwarnings("ignore", message="Non-optimal GB parameters detected for GB model HCT") - warnings.filterwarnings("ignore", message="Reload offsets from trajectory") - - from autosampler.engines.base import EngineFactory - - engine = EngineFactory.get(engine_name, **engine_kwargs) - engine.prepare(**prepare_kwargs) - return engine.run_production(**run_kwargs) +"""Per-iteration walker execution. -def _detect_gpu_ids() -> list[int]: - visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES") - if visible_devices: - try: - return [int(device.strip()) for device in visible_devices.split(",")] - except ValueError: - pass +Thin facade over the pluggable execution backends in +:mod:`autosampler.execution`. Builds one task per walker and dispatches the +batch to the configured backend (local multiprocessing, SLURM, or PBS), +returning per-walker success flags. The local backend preserves the original +GPU-slot scheduling behaviour. +""" - try: - import torch - - num_gpus = torch.cuda.device_count() - except ImportError: - num_gpus = 0 - if num_gpus <= 0: - return [0] - return list(range(num_gpus)) - - -def _reserved_gpu_slots( - gpu_ids: Optional[list[int]], max_workers: int, n_walkers: int -) -> list[int]: - reserved_gpu_ids = list(gpu_ids) if gpu_ids is not None else _detect_gpu_ids() - if not reserved_gpu_ids: - reserved_gpu_ids = [0] - worker_count = min(max_workers, len(reserved_gpu_ids), n_walkers) - if worker_count <= 0: - raise ValueError("max_workers must be greater than 0") - return reserved_gpu_ids[:worker_count] +from __future__ import annotations +from pathlib import Path +from typing import Optional -def _uses_gpu_slots( - engine_name: str, - engine_kwargs: dict, -) -> bool: - if engine_name == "amber": - return "cuda" in str(engine_kwargs.get("amber_executable", "")).lower() - if engine_name == "gromacs": - return any( - str(engine_kwargs.get(key, "")).lower() == "gpu" - for key in ( - "gromacs_mdrun_nb", - "gromacs_mdrun_pme", - "gromacs_mdrun_update", - "gromacs_mdrun_bonded", - ) - ) - if engine_name == "openmm": - platform = str(engine_kwargs.get("platform_name", "CUDA")).lower() - return platform not in {"cpu", "reference"} - return True +from autosampler.execution import build_walker_tasks, make_backend -def _execution_slots( +def run_iteration_parallel( engine_name: str, engine_kwargs: dict, - gpu_ids: Optional[list[int]], - max_workers: int, - n_walkers: int, -) -> list[int]: - if max_workers <= 0: - raise ValueError("max_workers must be greater than 0") - if _uses_gpu_slots(engine_name, engine_kwargs): - return _reserved_gpu_slots(gpu_ids, max_workers, n_walkers) - return [0 for _ in range(min(max_workers, n_walkers))] - - -def run_iteration_parallel(engine_name: str, engine_kwargs: dict, prepare_kwargs: dict, - walkers: list, steps: int, stride: int, - outdir: Path, iteration: int, max_workers: int = 8, - gpu_ids: Optional[list[int]] = None) -> list: - """Execute walker production runs over CPU worker slots or GPU device slots.""" + prepare_kwargs: dict, + walkers: list, + steps: int, + stride: int, + outdir: Path, + iteration: int, + max_workers: int = 8, + gpu_ids: Optional[list[int]] = None, + execution=None, +) -> list: + """Execute walker production runs via the configured execution backend.""" outdir.mkdir(parents=True, exist_ok=True) - import multiprocessing as mp - if not walkers: return [] - reserved_gpu_ids = _execution_slots( - engine_name, engine_kwargs, gpu_ids, max_workers, len(walkers) + tasks = build_walker_tasks( + engine_name=engine_name, + engine_kwargs=engine_kwargs, + prepare_kwargs=prepare_kwargs, + walkers=walkers, + steps=steps, + stride=stride, + outdir=outdir, + iteration=iteration, ) - worker_count = len(reserved_gpu_ids) - - ctx = mp.get_context('spawn') - results = [False] * len(walkers) - walker_iter = iter(enumerate(walkers)) - - def submit_walker(executor, gpu_id: int): - try: - idx, coords = next(walker_iter) - except StopIteration: - return None - - if engine_name == "amber": - suffix = amber_trajectory_suffix( - engine_kwargs.get("amber_trajectory_format", "auto"), - engine_kwargs.get("amber_executable", "pmemd"), - ) - else: - suffix = "xtc" - traj_out = outdir / f"iteration_{iteration}_{idx}.{suffix}" - run_kwargs = { - "run_index": idx, - "start_coords": coords, - "steps": steps, - "traj_out": traj_out, - "stride": stride, - "device_index": gpu_id, - } - future = executor.submit( - _worker_task, - engine_name, - engine_kwargs, - prepare_kwargs, - run_kwargs, - ) - return future, idx, gpu_id - - with ProcessPoolExecutor(max_workers=worker_count, mp_context=ctx) as executor: - active = {} - for gpu_id in reserved_gpu_ids: - submitted = submit_walker(executor, gpu_id) - if submitted is not None: - future, idx, assigned_gpu_id = submitted - active[future] = (idx, assigned_gpu_id) - - while active: - done, _ = wait(active, return_when=FIRST_COMPLETED) - for future in done: - idx, freed_gpu_id = active.pop(future) - results[idx] = future.result() - submitted = submit_walker(executor, freed_gpu_id) - if submitted is not None: - next_future, next_idx, assigned_gpu_id = submitted - active[next_future] = (next_idx, assigned_gpu_id) - - return results + backend = make_backend(execution, gpu_ids=gpu_ids, max_workers=max_workers) + return backend.execute(tasks) diff --git a/docs/analysis.md b/docs/analysis.md new file mode 100644 index 0000000..16be271 --- /dev/null +++ b/docs/analysis.md @@ -0,0 +1,60 @@ +# MSM analysis & plotting + +When `msm.enabled` is set, each iteration writes an `iter_*/msm.npz` with the +MSM diagnostics (timescales, VAMP-2, stationary distribution, transition matrix, +metastable populations, and the implied-timescale sweep). AutoSampler ships +utilities to turn these into figures. + +## One-command report + +```bash +autosampler-analyze --run-dir runs/adaptive_msm_vampnet +# -> runs/adaptive_msm_vampnet/analysis/convergence_report.png +``` + +The report is a 2x2 panel: VAMP-2 convergence, slowest-timescale convergence, +the CV free-energy surface, and the latest implied-timescale sweep (or MSM +network). Options: `--outfile`, `--temperature` (for free energies in kJ/mol). + +## Programmatic API + +Data utilities (no matplotlib required): + +```python +from autosampler.analysis import data + +series = data.load_msm_series("runs/my_run") # iterations, vamp2, timescales +latest = data.load_latest_msm("runs/my_run") # arrays of the last msm.npz +points = data.load_cv_points("runs/my_run") # all CV projections stacked + +# Free energies (kJ/mol, min shifted to 0): +F = data.free_energy_from_populations(latest["metastable_populations"]) +Fxy, xe, ye = data.free_energy_surface(points, bins=60, temperature=300.0) +``` + +Plotting (needs `pip install "autosampler[examples]"`); each function takes an +optional `ax` and returns it: + +```python +from autosampler.analysis import plots + +plots.plot_vamp2_convergence(series) +plots.plot_timescale_convergence(series) +plots.plot_implied_timescales(latest["its_lagtimes"], latest["its_timescales"]) +plots.plot_free_energy_surface(points) +plots.plot_metastable_free_energy(latest["metastable_populations"]) +plots.plot_msm_network(latest["transition_matrix"], latest["stationary_distribution"]) + +# Or the full multi-panel report: +plots.plot_convergence_report("runs/my_run", outfile="report.png") +``` + +## What to look for + +- **Implied timescales** should plateau (become flat in lag time) — the signal + that the MSM is Markovian at the chosen lag. +- **VAMP-2 / timescale convergence** flattening across iterations indicates the + sampling (and the MSM) have converged — the same signals the + [ConvergenceMonitor](msm.md) uses to stop automatically. +- The **free-energy surface** reveals basins and barriers in the CV space; the + **MSM network** summarises metastable states and their connectivity. diff --git a/docs/binning.md b/docs/binning.md new file mode 100644 index 0000000..10302b5 --- /dev/null +++ b/docs/binning.md @@ -0,0 +1,50 @@ +# Adaptive binning + +Bins stratify the CV space for the **density** and **weighted-ensemble** (`we`) +spawners. By default they are a **uniform grid** (`RegularBinner`): constant width +everywhere. That is suboptimal near barriers — a wide bin across a steep +free-energy region lets a walker slide back before it can reach the next bin +within the lag time, so weighted-ensemble flux across the barrier stalls; in flat +basins fine bins waste replicas. + +`binning.scheme` selects a **landscape-adaptive** alternative, recomputed every +iteration. All schemes are opt-in; `uniform` is the default and exactly +reproduces the previous behaviour. + +| `scheme` | What it does | +| --- | --- | +| `uniform` | Constant-width grid (default; `RegularBinner`). | +| `gradient` | **Equi-resistance** edges — boundaries at equal increments of `∫ exp(βF) dx ∝ ∫ 1/P dx`, so bins concentrate where the sampled density is low (barriers / steep regions) and widen in basins. | +| `mab` | Minimal-Adaptive-Binning style: uniform bins between the occupied extremes plus narrow "foothold" bins at the moving fronts. | +| `eigenvector` | Bin uniformly along the **leading (slowest) CV coordinate** only. For a learned CV / committor proxy this is automatically fine across the barrier and coarse in basins, and handles many CVs as a single 1-D coordinate. | + +## Configuration + +```yaml +binning: + scheme: gradient # uniform | gradient | mab | eigenvector + n_fine: 100 # density-histogram resolution (gradient) + smoothing: 3 # density smoothing window (gradient) + +n_bins: [30, 30] # number of bins per CV axis (target count for adaptive schemes) +``` + +## Notes + +- **Weighted ensemble:** the `we` spawner carries per-frame statistical weights + (not per-bin), so re-binning every iteration needs no weight remapping — the + adaptive bins simply change which bin each frame falls in. +- **Resolution:** the configured `n_bins` is the per-axis target count; the global + occupancy-driven resolution bump still applies and propagates to the adaptive + binner. +- **Choosing a scheme:** `gradient` is the rigorous default for resolving barriers + from the density alone; `eigenvector` is the cleanest choice with a learned CV + or when the MSM is enabled (it bins along the slow coordinate); `mab` suits + directed / front-pushing exploration. + +## Extending + +Register a new scheme by subclassing `AdaptiveBinner` +(`autosampler/binning/adaptive.py`) — implement `_axis_edges` (and optionally +`_coords`) — and adding it to `BinnerFactory`. It then works with both spawners +unchanged. diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..1316784 --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,56 @@ +# Concepts + +## Walkers, iterations, and spawning + +Each **iteration** runs a batch of short MD **walkers**. Saved frames are +projected into a CV space; a **spawner** then chooses which frames to restart +the next iteration's walkers from. Spawners: + +| `spawn_scheme` | Strategy | +| --- | --- | +| `density` | Restart from under-populated regions of a regular grid. | +| `voronoi` | Restart from sparse Voronoi (k-means) cells. | +| `lof` | Restart from statistical outliers (local outlier factor). | +| `fps` | Farthest-point sampling for maximal coverage. | +| `msm` | **MSM least-counts**: restart from microstates with the largest statistical uncertainty (drives MSM convergence). | +| `we` | **Weighted ensemble**: split/merge resampling with conserved statistical weights, keeping a target walker count per bin. | + +## CV spaces + +A run uses either: + +- **Fixed CVs** (`space_mode: fixed`) — a user `project_file` returning physical + CVs (dihedrals, distances, …), or +- **Learned CVs** (`space_mode: tica | tvae | vampnet | spib | deep-tica | pca`) + — trained on the fly from input features and periodically retrained + (`retrain_freq`). See [Collective variables](cv_methods.md). + +## Input features + +Learned CVs are trained on **input features** extracted from the trajectories: +pairwise `distances`, `fitted_coords`, or system-specific dihedrals, restricted +by the `feature_selection` atom mask. Optionally, a **VAMP-2 feature selector** +chooses and adaptively updates the best subset — see +[Feature selection](feature_selection.md). + +## MSM and convergence + +When `msm.enabled` is set, each iteration discretises the CV space into +microstates, estimates a transition matrix at a lag time, and computes implied +timescales, the VAMP-2 score, and PCCA+ metastable states. A +**ConvergenceMonitor** combines pluggable criteria (timescale stability, VAMP-2 +plateau, stationary-distribution drift, statistical error) to decide when +sampling is complete. See [MSM & convergence](msm.md). + +## Execution + +Walkers are dispatched by an **execution backend**: `local` (multi-GPU +workstation) or `slurm` / `pbs` (HPC array jobs). The choice is purely a config +setting and does not affect the science. See [Execution](execution.md). + +## Reproducibility & provenance + +- Global deterministic seeding (`random_seed`). +- Per-iteration checkpoints (`checkpoint_freq`) with `--resume`. +- Every frame carries lineage (`iteration:walker:frame` + parent), enabling + connected-path reconstruction with `autosampler-path`. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..66e0617 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,131 @@ +# Configuration reference + +A run is described by a single YAML file (paths are resolved relative to it). +All sections are validated by Pydantic; unknown or invalid values are rejected +at startup. Below, only non-obvious defaults are noted — see +`autosampler/config.py` for the authoritative schema. + +## `system` + +| Key | Default | Description | +| --- | --- | --- | +| `conf_file` | — | Coordinate file (`.gro`, `.pdb`, …). | +| `top_file` | — | Topology file. | +| `topology` | `amber` | `gromacs` \| `amber` \| `charmm`. | +| `system_file` | `None` | Optional Python module building a custom OpenMM `System`. | +| `project_file` | `None` | Python module with `extract_cvs(...)` for fixed CVs. | +| `trajectory_topology_file` | `None` | Topology used to read trajectories (defaults to `top_file`). | +| `feature_selection` | `protein and not (type H)` | MDAnalysis atom selection for features. | + +## `engine` + +| Key | Default | Description | +| --- | --- | --- | +| `md_engine` | `openmm` | `openmm` \| `gromacs` \| `amber`. | +| `platform_name` | `CUDA` | OpenMM platform (`CUDA`, `CPU`, `OpenCL`, `Reference`). | +| `precision` | `mixed` | OpenMM precision. | +| `temperature` / `pressure` / `dt` | `300` / `1.0` / `0.002` | Thermostat / barostat / timestep. | +| `npt` / `equilibrate` | `false` / `false` | Constant-pressure / pre-equilibration. | +| `gpu_ids` | `None` | Explicit GPU device ids for the local backend. | +| `gromacs_*` / `amber_*` | — | Engine-specific executables and `mdrun`/`pmemd` options. | + +!!! tip "MD timeouts" + Set the `AUTOSAMPLER_MD_TIMEOUT` environment variable (seconds) to guard + against hung GROMACS/Amber subprocesses. + +## `spawning` + +| Key | Default | Description | +| --- | --- | --- | +| `spawn_scheme` | `density` | `density` \| `voronoi` \| `lof` \| `fps` \| `msm` \| `we`. | +| `we_target_per_bin` | `4` | Weighted-ensemble walkers per occupied bin (`spawn_scheme: we`). | +| `spawn_type` | `hard` | `hard` or `probabilistic`. | +| `search_mode` | `explore` | `explore` or `target` (toward `target`). | +| `walker` / `step` / `stride` | `10` / `10000` / `100` | Walkers per iteration / MD steps / save interval. | +| `max_workers` | `4` | Concurrent walkers (local backend). | +| `voronoi_clusters` | `150` | Cells / microstates (also used by the MSM spawner). | +| `convergence_patience` | `0` | Bin-occupancy stall patience (legacy convergence). | + +## `space_mode` and adaptive model + +`space_mode`: `fixed` \| `pca` \| `tica` \| `tvae` \| `vampnet` \| `spib` \| +`deep-tica` \| `deep-lda`. For learned modes: + +| Key | Default | Description | +| --- | --- | --- | +| `adaptive_feature_type` | `distances` | `distances` \| `fitted_coords` \| `phi_psi`. | +| `retrain_freq` | `1` | Retrain the CV every N iterations (`fixed` policy). | +| `retrain_policy` | `fixed` | `fixed` or `vamp_adaptive` (retrain on VAMP-2 drop). | +| `vamp_retrain_tol` | `0.1` | Relative VAMP-2 drop that triggers a retrain. | +| `retrain_min_interval` / `retrain_max_interval` | `1` / `None` | Bounds between adaptive retrains. | +| `aggregate_memory` | `true` | Pool historical frames when retraining. | +| `max_adaptive_memory_frames` | `50000` | Cap on pooled frames. | +| `adaptive_model.lagtime` | `5` | Lag time for time-lagged CVs. | +| `adaptive_model.latent_dim` | `2` | CV dimensionality. | +| `adaptive_model.epochs` / `learning_rate` | `50` / `5e-4` | Training. | +| `adaptive_model.encoder_hidden_dims` | `[256,128]` | Network width. | +| `adaptive_model.spib_n_states` / `spib_beta` | `10` / `1e-3` | SPIB knobs. | + +## `feature_selection` + +VAMP-2 input-feature selection (opt-in). See [Feature selection](feature_selection.md). + +| Key | Default | Description | +| --- | --- | --- | +| `enabled` | `false` | Turn feature selection on. | +| `method` | `greedy_vamp` | `greedy_vamp` \| `all`. | +| `lagtime` | `10` | Lag time for VAMP-2 scoring. | +| `cadence` | `5` | Re-select every N iterations. | +| `max_features` | `None` | Cap on selected columns/groups. | +| `min_gain` | `1e-4` | Minimum VAMP-2 gain to keep adding features. | +| `candidate_feature_types` | `[]` | Rank these feature types by VAMP-2 and use the best (subset of `distances`/`fitted_coords`/`phi_psi`). | + +## `msm` + +Markov State Model estimation and convergence (opt-in). See [MSM](msm.md). + +| Key | Default | Description | +| --- | --- | --- | +| `enabled` | `false` | Build an MSM each iteration and use MSM convergence. | +| `cadence` / `min_frames` | `1` / `1000` | MSM cadence / minimum frames before first MSM. | +| `lagtime` / `lagtimes` | `10` / `None` | MSM lag / implied-timescale sweep. | +| `n_microstates` / `cluster_method` | `100` / `kmeans` | Discretisation. | +| `estimator` | `mle` | `mle` or `bayesian` (error bars). | +| `n_timescales` / `n_metastable` | `3` / `None` | Slow processes / PCCA+ states. | +| `stable_clustering` | `false` | Seed k-means from previous centres (comparable `T_ij` / microstate IDs across iterations). | +| `spawn_alpha` / `spawn_leverage` / `spawn_uncertainty` | `1.0` / `1` / `true` | MSM-guided spawner: exploration weight / # slow eigenvectors for leverage / include outflow uncertainty. | +| `convergence_criteria` | ITS + VAMP-2 | List of `{name, params}`; add `transition_matrix` for flux-weighted `T_ij` convergence. | +| `convergence_mode` / `convergence_patience` | `all` / `2` | Combine criteria / patience. | + +## `binning` + +Landscape-adaptive binning for the density / WE spawners. See [Adaptive +binning](binning.md). + +| Key | Default | Description | +| --- | --- | --- | +| `scheme` | `uniform` | `uniform` \| `gradient` \| `mab` \| `eigenvector`. | +| `n_fine` | `100` | Density-histogram resolution (gradient scheme). | +| `smoothing` | `3` | Density smoothing window (gradient scheme). | + +## `execution` + +Where walkers run (workstation vs HPC). See [Execution](execution.md). + +| Key | Default | Description | +| --- | --- | --- | +| `backend` | `local` | `local` \| `slurm` \| `pbs`. | +| `partition` / `account` / `walltime` | `None` / `None` / `01:00:00` | Scheduler resources. | +| `cpus_per_task` / `gpus_per_task` / `memory` | `1` / `0` / `None` | Per-walker resources. | +| `max_retries` | `1` | Resubmit failed walkers up to N times. | +| `poll_interval` / `submit_timeout` | `30` / `60` | Polling / command timeouts (s). | +| `module_loads` / `extra_directives` | `[]` / `[]` | `module load` lines / raw scheduler directives. | + +## Top-level + +| Key | Default | Description | +| --- | --- | --- | +| `outdir` | `runs/sampler_output` | Output directory. | +| `random_seed` | `42` | Global seed. | +| `checkpoint_freq` | `1` | Checkpoint every N iterations. | +| `n_bins` / `min_values` / `max_values` | `[30,30]` / — / — | Binning for coverage / fixed-space bounds. | diff --git a/docs/cv_methods.md b/docs/cv_methods.md new file mode 100644 index 0000000..6f0be94 --- /dev/null +++ b/docs/cv_methods.md @@ -0,0 +1,83 @@ +# Collective variables + +AutoSampler can sample in **fixed** physical CVs or **learn** CVs on the fly. +The available learned methods live in a single registry +(`autosampler/spaces/registry.py`), which also tracks each method's backend and +whether it is available in your environment. + +## Available methods + +| `space_mode` | Backend | Time-lagged | Notes | +| --- | --- | --- | --- | +| `pca` | scikit-learn | no | Linear baseline. | +| `tica` | deeptime | yes | Linear, dynamics-aware. | +| `tvae` | deeptime + torch | yes | Time-lagged variational autoencoder. | +| `vampnet` | deeptime + torch | yes | Deep CVs via the VAMP-2 variational principle. | +| `spib` | torch (built-in) | yes | State Predictive Information Bottleneck (Wang & Tiwary, 2021). | +| `deep-tica` | mlcolvar + lightning | yes | Deep nonlinear TICA (optional extra). | +| `deep-lda` | mlcolvar + lightning | no | Supervised; needs per-frame state labels (optional extra). | + +`fixed` mode uses a user `project_file` exposing +`extract_cvs(trajectories, top_file, conf_file) -> ndarray`. + +## Choosing a method + +- **Start simple:** `tica` (fast, robust, interpretable) or `pca`. +- **Nonlinear / deep CVs:** `vampnet` or `spib` are strong defaults and need no + extra packages beyond torch. +- **Supervised separation of known states:** `deep-lda`. + +## Configuring + +```yaml +space_mode: vampnet +adaptive_feature_type: distances # distances | fitted_coords | phi_psi +retrain_freq: 5 # retrain the CV every 5 iterations +adaptive_model: + lagtime: 5 + latent_dim: 2 + epochs: 50 + encoder_hidden_dims: [64, 32] + spib_n_states: 10 # used when space_mode: spib + spib_beta: 0.001 +``` + +## Adaptive retraining (VAMP-2 driven) + +A learned CV can go stale as new regions are discovered. By default it retrains +on a fixed schedule (`retrain_freq`). Set `retrain_policy: vamp_adaptive` to +retrain **only when the CV's VAMP-2 score on fresh data drops** below its +post-training reference — coupling retraining to sampling progress. + +```yaml +retrain_policy: vamp_adaptive # fixed | vamp_adaptive +vamp_retrain_tol: 0.1 # relative VAMP-2 drop that triggers a retrain +retrain_min_interval: 1 # don't retrain more often than this +retrain_max_interval: 20 # force a refresh at least this often (optional) +``` + +The controller's reference score is checkpointed, so `--resume` continues the +same policy. + +## Availability checks + +If a method's backend is missing, AutoSampler raises an actionable error, e.g.: + +```text +CV method 'deep-tica' requires missing package(s): mlcolvar, lightning. +Install via: pip install "autosampler[deep-tica]". +``` + +Programmatically: + +```python +from autosampler.spaces.registry import is_available, adaptive_modes +adaptive_modes() # ('pca','tica','tvae','vampnet','spib','deep-tica','deep-lda') +is_available("vampnet") # True / False +``` + +## Adding a new CV method + +Register a `CVMethod` in `autosampler/spaces/registry.py` and add a branch in +`AdaptiveSpaceModel.fit` / `.project`. The rest of the framework (training +cadence, MSM, spawning) works unchanged. diff --git a/docs/execution.md b/docs/execution.md new file mode 100644 index 0000000..94ea044 --- /dev/null +++ b/docs/execution.md @@ -0,0 +1,89 @@ +# Execution: workstation & HPC + +Where walkers run is a pure configuration choice via the `execution` section. +The science is identical across backends. + +## Local (multi-GPU workstation) + +The default. Walkers run as subprocesses across CPU worker slots or GPU device +slots, with GPU device ids assigned dynamically as workers free up. + +```yaml +execution: + backend: local +engine: + platform_name: CUDA + gpu_ids: [0, 1, 2, 3] # optional; auto-detected otherwise +spawning: + max_workers: 4 # concurrent walkers +``` + +GPU visibility per worker is set through `CUDA_VISIBLE_DEVICES`. With more +walkers than slots, walkers are streamed onto slots as they free up. + +## SLURM + +Each iteration's walkers are submitted as **one array job** +(`#SBATCH --array=0-N`). AutoSampler renders the script, submits with +`sbatch --parsable`, polls `squeue`, and collects per-walker result markers from +the shared filesystem. Failed or missing walkers are resubmitted up to +`max_retries` times. + +```yaml +execution: + backend: slurm + partition: gpu + account: my_alloc + walltime: "02:00:00" + cpus_per_task: 8 + gpus_per_task: 1 + memory: "16G" + max_retries: 2 + poll_interval: 30 + module_loads: + - "module load cuda/12.2" + - "module load openmm" + extra_directives: + - "#SBATCH --qos=normal" +``` + +## PBS / Torque (PBS Pro) + +The same model with PBS array jobs (`#PBS -J 0-N`, `qsub`, `qstat`): + +```yaml +execution: + backend: pbs + partition: gpuq # PBS queue + walltime: "02:00:00" + cpus_per_task: 8 + gpus_per_task: 1 + memory: "16gb" + module_loads: + - "module load openmm" +``` + +## How it works + +- Each walker is a self-contained task pickled to the iteration's `_jobs/` + directory. An array element loads its task and runs + `python -m autosampler.execution.run_task`, writing a JSON **result marker**. +- **Completion is filesystem-driven** (result markers), not scheduler + accounting — robust to flaky queue state. A walker that dies without a marker + is treated as failed and resubmitted. +- Requirements: a **shared filesystem** visible to compute nodes, and the + `autosampler` package importable in the job environment (hence `module_loads` + / activating your conda env in the job, e.g. via `extra_directives`). + +## Choosing resources + +`cpus_per_task` / `gpus_per_task` / `memory` are **per walker** (one array +element). For CPU-only HPC, set `gpus_per_task: 0` and an OpenMM `CPU` +platform (or a CPU GROMACS/Amber build) and scale out across many array tasks. + +## Adding a scheduler + +Subclass `SchedulerBackend` (`autosampler/execution/scheduler.py`), implement +the directive/submit/poll hooks, and call +`ExecutionBackendFactory.register(...)`. The submit → poll → collect → retry +machinery is inherited. diff --git a/docs/feature_selection.md b/docs/feature_selection.md new file mode 100644 index 0000000..d03b15d --- /dev/null +++ b/docs/feature_selection.md @@ -0,0 +1,85 @@ +# Feature selection (VAMP-2) + +The quality of a learned CV — and the MSM built on it — is bounded by the +**input features** fed to it. AutoSampler can **select and adaptively update** +those features automatically using the **VAMP-2 score**, a variational measure +of how much slow kinetic variance a feature set captures (Wu & Noé 2017; +Scherer et al. 2019). Higher VAMP-2 = better features. + +## How input features are produced + +For learned CVs, features are extracted from each trajectory according to: + +- `adaptive_feature_type`: `distances` (pairwise distances), `fitted_coords` + (RMSD-aligned Cartesian coordinates), or `phi_psi` (system dihedrals); +- `feature_selection`: the MDAnalysis atom mask restricting which atoms + contribute. + +Without VAMP-2 selection these features are fixed for the whole run. With it, +AutoSampler keeps the **subset of feature columns that best resolves the slow +dynamics**, and refreshes that subset as more of the landscape is explored. + +## Enabling it + +```yaml +feature_selection: + enabled: true + method: greedy_vamp # greedy forward selection (or `all` to keep everything) + lagtime: 10 # lag time for VAMP-2 scoring + cadence: 5 # re-select every 5 iterations (adaptive update) + max_features: 50 # optional cap on the number of selected columns + min_gain: 1.0e-4 # stop adding columns when the VAMP-2 gain is tiny +``` + +The selected columns are applied consistently at CV training, projection, and +historical re-projection, and are saved in the checkpoint so `--resume` keeps +the same selection. + +## The optimisation protocol + +`method: greedy_vamp` runs **greedy forward selection**: starting from no +features, it repeatedly adds the column whose inclusion most increases the +VAMP-2 score, stopping once no column improves the score by more than +`min_gain` (or `max_features` is reached). Because VAMP-2 is monotonic in the +number of features, the `min_gain` threshold is what yields a *parsimonious* +feature set that retains essentially all of the kinetic variance. + +## Using the API directly + +```python +import numpy as np +from autosampler.spaces.feature_selection import ( + vamp2_score, rank_candidates, greedy_vamp_selection, FeatureSelector, +) + +# trajs: list of (n_frames, n_features) arrays, one per walker +score = vamp2_score(trajs, lagtime=10) + +# Compare candidate feature sets: +rank_candidates({"distances": d_trajs, "dihedrals": phi_trajs}, lagtime=10) + +# Optimise the column subset: +cols = greedy_vamp_selection(trajs, lagtime=10, max_groups=20) + +# Or via the orchestrator used by the loop: +sel = FeatureSelector(lagtime=10, method="greedy_vamp").select(trajs) +sel.columns, sel.score +``` + +## Choosing among feature *types* + +Beyond selecting columns within one feature type, AutoSampler can rank whole +**feature types** by VAMP-2 and use the best one. List the candidates and the +loop extracts each, ranks them, and switches to the winner (re-running column +selection when the type changes): + +```yaml +feature_selection: + enabled: true + candidate_feature_types: [distances, fitted_coords] # subset of: + # distances | fitted_coords | phi_psi + cadence: 5 # re-rank types every 5 iterations +``` + +When `candidate_feature_types` is empty (default) the top-level +`adaptive_feature_type` is used. The chosen type is checkpointed for resume. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..62a5a2d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,51 @@ +# AutoSampler + +**AutoSampler** is a modular framework for **autonomous adaptive molecular +dynamics sampling**. It runs many short MD walkers, projects frames into a +fixed or machine-learned collective-variable (CV) space, restarts walkers from +informative regions, and repeats — continuing until a **Markov State Model +(MSM)** built on the sampled data has **converged**. + +## Why AutoSampler + +- **MSM-convergence driven.** Sampling proceeds until implied timescales and the + VAMP-2 score plateau, *and* the flux-weighted statistical error on the + transition matrix falls below threshold — not just until bins fill up. +- **Landscape-adaptive binning.** Optionally place bins finer across barriers and + coarser in basins (`gradient` / `mab` / `eigenvector`), recomputed each + iteration, instead of a uniform grid. +- **Learned or fixed CVs.** Use physical CVs (dihedrals, distances, …) or learn + them on the fly: TICA, TVAE, **VAMPNet**, **SPIB**, deep-TICA, PCA. +- **VAMP-2 feature optimisation.** Optionally select and adaptively update the + input features that best resolve the slow dynamics. +- **Runs anywhere.** A multi-GPU workstation (local backend) or CPU/GPU HPC + clusters via **SLURM** or **PBS** array jobs, with automatic resubmission. +- **Reproducible.** Deterministic seeding, full checkpoint/restart, and + lineage-aware path reconstruction. + +## The adaptive loop + +```text + ┌─────────────────────────────────────────────────────────┐ + │ run short MD walkers (local / SLURM / PBS) │ + │ │ │ + │ extract features ──► (VAMP-2 feature selection) ──┐ │ + │ │ │ │ + │ train / update CV (TICA / VAMPNet / SPIB / …) ◄──┘ │ + │ │ │ + │ build MSM (clusters → T(τ) → ITS / VAMP-2 / PCCA+) │ + │ │ │ + │ converged? ──yes──► stop │ + │ │no │ + │ spawn new walkers (MSM least-counts / density / …) │ + └────────────┴────────────────────────────────────────────┘ +``` + +## Where to go next + +- New here? Start with the **[Quickstart](quickstart.md)**. +- Want the full picture? Read **[Concepts](concepts.md)**. +- Configuring a run? See the **[Configuration reference](configuration.md)**. +- Tuning where bins go? See **[Adaptive binning](binning.md)**. +- Running on a cluster? See **[Execution](execution.md)**. +- A worked end-to-end example: the **[adaptive-MSM tutorial](tutorials/adaptive_msm.md)**. diff --git a/docs/input_file.md b/docs/input_file.md new file mode 100644 index 0000000..921716a --- /dev/null +++ b/docs/input_file.md @@ -0,0 +1,83 @@ +# The input file + +Everything about a run — the system, MD engine, sampling method, CV space, +feature selection, MSM convergence, and where jobs execute — is described by a +single YAML **input file**. There is no code to write for a standard run. + +## Get a starter file + +```bash +autosampler-init # writes ./config.yaml (annotated template) +autosampler-init -o my_run.yaml # custom path +``` + +Then edit it and validate before running: + +```bash +autosampler --config config.yaml --check # checks files, engine, settings +autosampler --config config.yaml --iterations 200 +``` + +The starter file is also at `examples/template.yaml`, and worked examples live +under `examples/AlaD/` and `examples/AIB9/`. + +## Structure + +The file has one block per concern. Only `system` (and `project_file` for +`space_mode: fixed`) is mandatory; everything else has sensible defaults, and +the advanced blocks (`feature_selection`, `msm`, `execution`, retrain policy) +are **opt-in**. + +| Block | Selects | +| --- | --- | +| `system` | structure, topology, and the atom mask for features | +| `engine` | MD backend (OpenMM/GROMACS/Amber) and thermodynamics | +| `spawning` | how the next walkers are chosen, and walker/step counts | +| `space_mode` + `adaptive_model` | the CV method and its hyperparameters | +| `feature_selection` | VAMP-2 selection/optimisation of input features | +| `msm` | MSM estimation and the convergence criteria that stop the run | +| `execution` | where walkers run: workstation, SLURM, or PBS | +| run-level keys | `outdir`, `random_seed`, `checkpoint_freq`, … | + +## Choosing methods (the knobs that matter most) + +**Sampling method** — `spawning.spawn_scheme`: +`density` · `voronoi` · `lof` · `fps` · `msm` (least-counts, drives MSM +convergence) · `we` (weighted ensemble). + +**CV method** — `space_mode`: +`fixed` (your `project_file`) · `pca` · `tica` · `tvae` · `vampnet` · `spib` · +`deep-tica` · `deep-lda`. Hyperparameters live in `adaptive_model` +(`lagtime`, `latent_dim`, `epochs`, `encoder_hidden_dims`, …). See +[Collective variables](cv_methods.md). + +**Features** — `adaptive_feature_type` (`distances`/`fitted_coords`/`phi_psi`) +restricted by `system.feature_selection`. Turn on +[`feature_selection`](feature_selection.md) to let VAMP-2 pick the best subset +and/or feature type automatically. + +**Convergence** — set `msm.enabled: true` to build an MSM each iteration and +stop when `msm.convergence_criteria` are satisfied (implied timescales, VAMP-2, +statistical error). See [MSM & convergence](msm.md). + +**Where it runs** — `execution.backend`: `local` (multi-GPU workstation) or +`slurm` / `pbs` (HPC array jobs). See [Execution](execution.md). + +## Annotated template + +```yaml +--8<-- "examples/template.yaml" +``` + +!!! note + The snippet above is the exact file `autosampler-init` writes. Every field + is documented inline; the [Configuration reference](configuration.md) lists + all keys, defaults, and allowed values in table form. + +## How settings flow + +`autosampler --config config.yaml` loads the YAML, validates it against the +schema (`autosampler/config.py`), resolves relative paths, and runs the adaptive +loop. Invalid values (e.g. an unknown `space_mode` or `spawn_scheme`) are +rejected immediately with a clear message, before any MD is launched. +``` diff --git a/docs/msm.md b/docs/msm.md new file mode 100644 index 0000000..6ab3870 --- /dev/null +++ b/docs/msm.md @@ -0,0 +1,89 @@ +# MSM & convergence + +With `msm.enabled: true`, every iteration (subject to `cadence` and +`min_frames`) AutoSampler builds a **Markov State Model** from the cumulative +sampled data and uses it to decide when sampling is **complete**. + +## What the MSM estimator does + +`autosampler/msm/estimator.py` (`MSMEstimator`, built on `deeptime`) runs: + +1. **Discretise** the CV/latent space into microstates (`cluster_method`: + `kmeans` or `regspace`, `n_microstates`). +2. **Count** transitions at `lagtime`, restricting to the largest connected set. +3. **Estimate** the transition matrix — `mle` (maximum likelihood) or + `bayesian` (posterior samples for statistical error bars). +4. **Analyse**: implied timescales, **VAMP-2** score, **PCCA+** metastable + states (`n_metastable`), and the stationary distribution / free energy. +5. Optionally sweep `lagtimes` for an implied-timescale diagnostic. + +Results are written to `iter_*/msm.npz` and checkpointed. + +## Convergence criteria + +A `ConvergenceMonitor` combines pluggable criteria; sampling stops when the +chosen combination holds for `convergence_patience` consecutive iterations. + +| Criterion (`name`) | Triggers when… | +| --- | --- | +| `implied_timescales` | The slowest `n_timescales` implied timescales change by less than `tol` (relative). | +| `vamp2` | The VAMP-2 score change falls below `tol`. | +| `stationary_distribution` | The stationary distribution drift (L1/KL) falls below `tol`. | +| `statistical_error` | The Bayesian relative error on the slow timescales falls below `tol`. | +| `transition_matrix` | The largest **flux-weighted** relative uncertainty of the microstate transition probabilities falls below `tol` (analytic Dirichlet error; `min_flux` ignores negligible transitions). Combine under `mode: all` with a spectral criterion to require *both* kinetic resolution and statistical convergence of `T_ij`. | + +```yaml +msm: + enabled: true + lagtime: 10 + lagtimes: [1, 2, 5, 10, 20] + n_microstates: 100 + estimator: bayesian + n_metastable: 4 + convergence_mode: all # all | any + convergence_patience: 3 + convergence_criteria: + - name: implied_timescales + params: {tol: 0.1, n_timescales: 2} + - name: vamp2 + params: {tol: 0.05} + - name: statistical_error + params: {tol: 0.2} +``` + +## MSM-guided spawning + +Pair MSM convergence with `spawn_scheme: msm` to **drive** convergence: the +MSM least-counts spawner restarts walkers from microstates with the largest +statistical uncertainty, reducing the error on the slow processes fastest. + +```yaml +spawning: + spawn_scheme: msm + voronoi_clusters: 100 # microstate count for the least-counts fallback +msm: + enabled: true + stable_clustering: true # keep microstate IDs comparable across iterations + spawn_alpha: 1.0 # weight of the exploration / least-counts term + spawn_leverage: 1 # # slow eigenvectors used for the leverage factor + spawn_uncertainty: true # include the outflow-uncertainty factor +``` + +When the MSM is available, the spawner scores each microstate by +**uncertainty × leverage × flux**: `π_i · |ψ_i| · (σ_out,i / mean) + α/√c_i` — +its stationary flux, its amplitude on the slow eigenvectors (leverage), and the +Dirichlet statistical uncertainty of its outgoing transitions, plus a +least-counts exploration term. New / disconnected microstates receive the +exploration weight so they get connected. Before the first MSM is built (or right +after a resume) it falls back to plain least-counts. With large `spawn_alpha` (or +`spawn_uncertainty: false`) it reduces to least-counts. `stable_clustering` seeds +each k-means from the previous centres so `T_ij` and microstate IDs stay +comparable across iterations. + +## Practical notes + +- Short walkers can produce **disconnected** counts early on; the estimator + restricts to the largest connected set and early MSMs should be treated as + diagnostics. Use `min_frames` to delay the first MSM. +- Build cost scales with frames; raise `cadence` to estimate the MSM less often. +- All MSM behaviour is **off by default**, so non-MSM runs are unaffected. diff --git a/docs/notebook_tutorial.md b/docs/notebook_tutorial.md new file mode 100644 index 0000000..c16bcd8 --- /dev/null +++ b/docs/notebook_tutorial.md @@ -0,0 +1,29 @@ +# Notebook tutorial + +A runnable Jupyter notebook with **rendered plots** lives at +[`examples/notebooks/adaptive_msm_tutorial.ipynb`](https://github.com/TeamSuman/AutoSampler/blob/devel/examples/notebooks/adaptive_msm_tutorial.ipynb). +It uses small synthetic examples so every figure renders in seconds without +running molecular dynamics. + +It covers, end to end: + +1. **The input file** — load the annotated template and validate it against the + schema. +2. **VAMP-2 feature selection** — rank candidate feature sets and pick the + informative ones. +3. **MSM estimation** — build an MSM from a synthetic metastable chain and plot + implied timescales and metastable free energies. +4. **Convergence detection** — watch the `ConvergenceMonitor` fire. +5. **Weighted-ensemble resampling** — split/merge with conserved weight. +6. **The analysis report** — synthesise a run directory and render the + multi-panel `plot_convergence_report`. + +## Run it yourself + +```bash +pip install -e ".[deep-tica,examples]" jupyter +jupyter notebook examples/notebooks/adaptive_msm_tutorial.ipynb +``` + +Then continue with a real campaign using the [input file](input_file.md) and the +[adaptive-MSM tutorial](tutorials/adaptive_msm.md). diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..9cd6c51 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,68 @@ +# Quickstart + +## Install + +```bash +conda env create -f env.yml +conda activate autosampler +pip install -e ".[deep-tica]" # optional deep-TICA / deep-LDA backends +``` + +For a lightweight environment (MSM, CV registry, feature selection, and tests +without the heavy MD backends): + +```bash +pip install numpy scipy scikit-learn pydantic pyyaml deeptime pytest torch +``` + +## Validate a configuration + +`--check` runs all preflight checks (files, executables, settings) and exits +without launching MD: + +```bash +autosampler --config examples/AlaD/config.yaml --check +``` + +## Run + +```bash +# Fixed phi/psi CVs, density spawning, 20 iterations +autosampler --config examples/AlaD/config.yaml --iterations 20 + +# Adaptive VAMPNet CV + MSM convergence (stops automatically) +autosampler --config examples/AIB9/config_msm_vampnet.yaml --iterations 200 +``` + +When `msm.enabled` is set, the run stops as soon as the MSM converges, even if +the iteration budget is not exhausted. + +## Resume + +Every iteration is checkpointed. Resume from the latest (or a specific) one: + +```bash +autosampler --config examples/AIB9/config_msm_vampnet.yaml --resume --iterations 50 +autosampler --config examples/AIB9/config_msm_vampnet.yaml --resume 12 --iterations 50 +``` + +## Inspect results + +```bash +# Per-iteration coverage / timing log +autosampler-log --run-dir runs/adaptive_msm_vampnet + +# MSM analysis report (VAMP-2 / timescales / free energy / network) +autosampler-analyze --run-dir runs/adaptive_msm_vampnet + +# Reconstruct a connected path between two CV points +autosampler-path \ + --run-dir runs/alad_phi_psi_density \ + --topology examples/AlaD/start.gro \ + --start=-1.05,-0.70 --end=1.05,0.70 \ + --output alad_path.xtc +``` + +Each `iter_*/` directory holds the trajectories, `cvs.npz`, optional +`features.npz`, and (when enabled) `msm.npz`. `output.log` is a tab-separated +per-iteration record. diff --git a/docs/tutorials/adaptive_msm.md b/docs/tutorials/adaptive_msm.md new file mode 100644 index 0000000..7bf775c --- /dev/null +++ b/docs/tutorials/adaptive_msm.md @@ -0,0 +1,135 @@ +# Tutorial: adaptive sampling to MSM convergence + +This walkthrough runs AutoSampler on the **AIB9** peptide, learning a VAMPNet CV +on the fly, building an MSM each iteration, and stopping automatically when the +MSM converges. It uses the shipped example +`examples/AIB9/config_msm_vampnet.yaml`. + +## 1. Environment + +```bash +conda env create -f env.yml +conda activate autosampler +pip install -e ".[deep-tica]" +``` + +## 2. Inspect the configuration + +Key settings (see the file for the full version): + +```yaml +space_mode: vampnet # learn a deep CV +adaptive_feature_type: distances +retrain_freq: 5 # retrain the CV every 5 iterations + +spawning: + spawn_scheme: msm # MSM least-counts seeding + walker: 10 + step: 5000 + stride: 50 + +msm: + enabled: true + lagtime: 10 + estimator: bayesian + n_metastable: 4 + convergence_mode: all + convergence_patience: 3 + stable_clustering: true # comparable microstate IDs across iterations + spawn_uncertainty: true # uncertainty × leverage × flux seeding + convergence_criteria: + - {name: implied_timescales, params: {tol: 0.1, n_timescales: 2}} + - {name: vamp2, params: {tol: 0.05}} + - {name: transition_matrix, params: {tol: 0.2, min_flux: 1.0e-4}} +``` + +With `convergence_mode: all`, the run stops only when the slow kinetics have +stabilised **and** the flux-weighted statistical uncertainty of the transition +matrix `T_ij` has fallen below `tol` — so a plateau in timescales alone will not +end the run while important transitions are still poorly estimated. The +`spawn_scheme: msm` spawner above actively drives that uncertainty down by +seeding walkers from microstates scored by **uncertainty × leverage × flux** +(see [MSM & convergence](../msm.md)). + +### Adaptive binning (optional) + +The density / WE spawners bin the CV space on a uniform grid by default. Switch +to landscape-adaptive bins — finer across barriers, coarser in basins, +recomputed each iteration — with a `binning` block (see +[Adaptive binning](../binning.md)): + +```yaml +binning: + scheme: gradient # uniform | gradient | mab | eigenvector +``` + +Optionally enable VAMP-2 feature selection (see +[Feature selection](../feature_selection.md)): + +```yaml +feature_selection: + enabled: true + method: greedy_vamp + lagtime: 10 + cadence: 5 +``` + +## 3. Preflight + +```bash +autosampler --config examples/AIB9/config_msm_vampnet.yaml --check +``` + +This validates inputs, the engine, and settings without running MD. + +## 4. Run + +```bash +autosampler --config examples/AIB9/config_msm_vampnet.yaml --iterations 200 --log-level INFO +``` + +Each iteration prints a summary banner and appends a row to +`runs/adaptive_msm_vampnet/output.log`. The run **stops early** when the +ConvergenceMonitor reports convergence: + +```text +Converged: implied timescales and VAMP-2 score satisfied for 3 iterations. +``` + +## 5. What gets written + +```text +runs/adaptive_msm_vampnet/ +├── output.log # per-iteration metrics +├── iter_0/ … iter_N/ +│ ├── iteration_*_*.xtc # walker trajectories +│ ├── cvs.npz # CV projections +│ ├── features.npz # input features (save_features: true) +│ └── msm.npz # MSM diagnostics (timescales, VAMP-2, π, PCCA+) +└── checkpoints/iter_*/ # resumable state +``` + +## 6. Resume if needed + +```bash +autosampler --config examples/AIB9/config_msm_vampnet.yaml --resume --iterations 100 +``` + +## 7. Analyse + +```bash +autosampler-log --run-dir runs/adaptive_msm_vampnet +``` + +Load the MSM diagnostics for plotting: + +```python +import numpy as np +data = np.load("runs/adaptive_msm_vampnet/iter_40/msm.npz", allow_pickle=True) +print(sorted(data.files)) # timescales, vamp2_score, stationary_distribution, ... +``` + +## 8. Run it on a cluster + +Switch only the `execution` section to scale out — see +[Execution](../execution.md). No other changes are needed. diff --git a/examples/AIB9/config_msm_feature_selection.yaml b/examples/AIB9/config_msm_feature_selection.yaml new file mode 100644 index 0000000..77a0166 --- /dev/null +++ b/examples/AIB9/config_msm_feature_selection.yaml @@ -0,0 +1,98 @@ +# Adaptive MSM sampling with VAMP-2 feature selection, ready for HPC. +# +# Extends config_msm_vampnet.yaml with: +# * feature_selection: VAMP-2 optimisation of the input features (opt-in), and +# * execution: SLURM array-job dispatch (switch backend to `local` or `pbs`). +# See docs/feature_selection.md and docs/execution.md. + +system: + conf_file: aib9_equilibrated.pdb + top_file: aib9_equilibrated.pdb + topology: charmm + system_file: system.py + trajectory_topology_file: aib9_equilibrated.pdb + feature_selection: "resname AIB and name N CA C O CB1 CB2" + +engine: + md_engine: openmm + platform_name: CUDA + precision: mixed + npt: true + temperature: 400.0 + dt: 0.002 + +spawning: + spawn_scheme: msm + spawn_type: probabilistic + search_mode: explore + walker: 16 + step: 5000 + stride: 50 + max_workers: 4 + voronoi_clusters: 100 + +space_mode: vampnet +adaptive_feature_type: distances +aggregate_memory: true +max_adaptive_memory_frames: 5000 +retrain_freq: 5 + +adaptive_model: + lagtime: 5 + latent_dim: 2 + epochs: 50 + encoder_hidden_dims: [64, 32] + +# VAMP-2 input-feature selection: keep and adaptively update the feature +# columns that best resolve the slow dynamics. +feature_selection: + enabled: true + method: greedy_vamp + lagtime: 10 + cadence: 5 # re-select every 5 iterations + max_features: 50 + min_gain: 1.0e-4 + +msm: + enabled: true + cadence: 1 + min_frames: 2000 + lagtime: 10 + lagtimes: [1, 2, 5, 10, 20] + n_microstates: 100 + cluster_method: kmeans + estimator: bayesian + n_bayesian_samples: 50 + n_timescales: 3 + n_metastable: 4 + convergence_mode: all + convergence_patience: 3 + convergence_criteria: + - name: implied_timescales + params: {tol: 0.1, n_timescales: 2} + - name: vamp2 + params: {tol: 0.05} + - name: statistical_error + params: {tol: 0.2} + +# Dispatch walkers as SLURM array jobs (one array job per iteration). +# Switch backend to `local` for a workstation or `pbs` for PBS/Torque. +execution: + backend: slurm + partition: gpu + account: my_alloc + walltime: "02:00:00" + cpus_per_task: 8 + gpus_per_task: 1 + memory: "16G" + max_retries: 2 + poll_interval: 30 + module_loads: + - "module load cuda/12.2" + # - "source $(conda info --base)/etc/profile.d/conda.sh && conda activate autosampler" + +outdir: runs/adaptive_msm_feature_selection +random_seed: 7 +checkpoint_freq: 1 +save_features: true +n_bins: [30, 30] diff --git a/examples/AIB9/config_msm_vampnet.yaml b/examples/AIB9/config_msm_vampnet.yaml new file mode 100644 index 0000000..0c74840 --- /dev/null +++ b/examples/AIB9/config_msm_vampnet.yaml @@ -0,0 +1,86 @@ +# Adaptive sampling driven to MSM convergence. +# +# This example learns a VAMPNet collective variable on the fly, builds a Markov +# State Model each iteration, spawns new walkers from under-sampled microstates +# (MSM least-counts), and stops automatically once the MSM has converged +# (implied timescales and VAMP-2 score plateau). Set `space_mode` to any of +# `vampnet`, `spib`, `tvae`, `tica`, `deep-tica`, or `pca`. + +system: + conf_file: aib9_equilibrated.pdb + top_file: aib9_equilibrated.pdb + topology: charmm + system_file: system.py + trajectory_topology_file: aib9_equilibrated.pdb + feature_selection: "resname AIB and name N CA C O CB1 CB2" + +engine: + md_engine: openmm + platform_name: CUDA + precision: mixed + npt: true + temperature: 400.0 + dt: 0.002 + +spawning: + spawn_scheme: msm # uncertainty × leverage × flux microstate seeding + spawn_type: probabilistic + search_mode: explore + walker: 10 + step: 5000 + stride: 50 + max_workers: 1 + voronoi_clusters: 100 # reused by the MSM spawner as the microstate count + +space_mode: vampnet # cutting-edge ML CV (deeptime VAMPNet) +adaptive_feature_type: distances +aggregate_memory: true +max_adaptive_memory_frames: 5000 +retrain_freq: 5 # retrain the CV every 5 iterations + +adaptive_model: + lagtime: 5 + latent_dim: 2 + epochs: 50 + encoder_hidden_dims: [64, 32] + # SPIB-specific knobs (used when space_mode: spib) + spib_n_states: 10 + spib_beta: 0.001 + +msm: + enabled: true + cadence: 1 # build the MSM every iteration + min_frames: 2000 # wait for enough data before the first MSM + lagtime: 10 + lagtimes: [1, 2, 5, 10, 20] # implied-timescale sweep (diagnostics) + n_microstates: 100 + cluster_method: kmeans + estimator: bayesian # posterior error bars on the slow timescales + n_bayesian_samples: 50 + n_timescales: 3 + n_metastable: 4 # PCCA+ coarse-graining + stable_clustering: true # keep microstate IDs comparable across iterations + spawn_uncertainty: true # weight seeding by outflow uncertainty (× leverage × flux) + spawn_leverage: 1 # # slow eigenvectors used for the leverage factor + convergence_mode: all # require BOTH kinetic resolution and T_ij convergence + convergence_patience: 3 + convergence_criteria: + - name: implied_timescales + params: {tol: 0.1, n_timescales: 2} + - name: vamp2 + params: {tol: 0.05} + - name: transition_matrix # flux-weighted Dirichlet uncertainty on T_ij + params: {tol: 0.2, min_flux: 1.0e-4} + +# Landscape-adaptive binning for the spawner: finer bins across barriers, +# coarser in basins (recomputed each iteration). `uniform` is the default. +binning: + scheme: gradient # uniform | gradient | mab | eigenvector + n_fine: 100 # density-histogram resolution (gradient) + smoothing: 3 # density smoothing window (gradient) + +outdir: runs/adaptive_msm_vampnet +random_seed: 7 +checkpoint_freq: 1 +save_features: true +n_bins: [30, 30] diff --git a/examples/notebooks/adaptive_msm_tutorial.ipynb b/examples/notebooks/adaptive_msm_tutorial.ipynb new file mode 100644 index 0000000..c7d87e4 --- /dev/null +++ b/examples/notebooks/adaptive_msm_tutorial.ipynb @@ -0,0 +1,468 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "27262cba", + "metadata": {}, + "source": [ + "# AutoSampler tutorial: adaptive sampling to MSM convergence\n", + "\n", + "This notebook walks through the core building blocks of **AutoSampler** with\n", + "small, fast, synthetic examples so every plot renders here without running\n", + "molecular dynamics:\n", + "\n", + "1. The **input file** that configures a run\n", + "2. **VAMP-2 feature selection**\n", + "3. **MSM estimation** (timescales, metastable states)\n", + "4. **Convergence detection**\n", + "5. **Weighted-ensemble** resampling\n", + "6. The **analysis report** utilities\n", + "\n", + "To run a *real* campaign, write an input file (`autosampler-init`) and launch\n", + "`autosampler --config config.yaml --iterations 200`. See the docs for details.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "ca4f3554", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-16T13:51:14.155768Z", + "iopub.status.busy": "2026-06-16T13:51:14.155560Z", + "iopub.status.idle": "2026-06-16T13:51:15.318443Z", + "shell.execute_reply": "2026-06-16T13:51:15.316900Z" + } + }, + "outputs": [], + "source": [ + "import warnings, numpy as np, matplotlib.pyplot as plt\n", + "warnings.filterwarnings(\"ignore\")\n", + "np.random.seed(0)\n" + ] + }, + { + "cell_type": "markdown", + "id": "44f18e6f", + "metadata": {}, + "source": [ + "## 1. The input file\n", + "\n", + "A single YAML file describes a whole run. `autosampler-init` writes an annotated\n", + "template; here we load it and validate it against the schema." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "769c2d0f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-16T13:51:15.321788Z", + "iopub.status.busy": "2026-06-16T13:51:15.321451Z", + "iopub.status.idle": "2026-06-16T13:51:15.488928Z", + "shell.execute_reply": "2026-06-16T13:51:15.487560Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "space_mode : vampnet\n", + "spawn_scheme : density\n", + "CV hyperparams : 2 dims, lag 5\n", + "execution : local\n", + "msm.enabled : False\n", + "# ============================================================================\n", + "# AutoSampler input file\n", + "#\n", + "# A single YAML file fully describes a run: the system, MD engine, how walkers\n", + "# are spawned, the collective-variable (CV) space, optional feature selection\n", + "# and MSM-convergence, and where jobs execute. Paths are resolved relative to\n", + "# this file. Validate before running: \n" + ] + } + ], + "source": [ + "import yaml\n", + "from autosampler.templates import DEFAULT_TEMPLATE\n", + "from autosampler.config import AutoSamplerConfig\n", + "\n", + "cfg = AutoSamplerConfig(**yaml.safe_load(DEFAULT_TEMPLATE))\n", + "print(\"space_mode :\", cfg.space_mode)\n", + "print(\"spawn_scheme :\", cfg.spawning.spawn_scheme)\n", + "print(\"CV hyperparams :\", cfg.adaptive_model.latent_dim, \"dims, lag\",\n", + " cfg.adaptive_model.lagtime)\n", + "print(\"execution :\", cfg.execution.backend)\n", + "print(\"msm.enabled :\", cfg.msm.enabled)\n", + "print(DEFAULT_TEMPLATE[:380])\n" + ] + }, + { + "cell_type": "markdown", + "id": "01a15914", + "metadata": {}, + "source": [ + "## 2. VAMP-2 feature selection\n", + "\n", + "VAMP-2 scores how well a feature set resolves the slow dynamics. Here one\n", + "feature carries a slow two-state signal and the rest are noise; selection should\n", + "prefer the informative feature." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "b5a10b6f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-16T13:51:15.491567Z", + "iopub.status.busy": "2026-06-16T13:51:15.491276Z", + "iopub.status.idle": "2026-06-16T13:51:16.011316Z", + "shell.execute_reply": "2026-06-16T13:51:16.010066Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "VAMP-2 ranking: [('slow feature', 0.432), ('noise features', 0.001)]\n", + "greedy selection keeps columns: [0]\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAcoAAAEoCAYAAADYELFVAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlcelbwAAAAlwSFlzAAAPYQAAD2EBqD+naQAANDpJREFUeJzt3XdUVNfePvAHBhikF0VUECMo9ooNERVsWC4ximjsQWNDjZpcNeaaGI2aXPO+JmqIsWGMir1gFwRLQhErFiwICBFFI02kCOzfH/48rxPgOMCYUXw+a81azD7n7PM9wwwPp+3REUIIEBERUal0tV0AERHRm4xBSUREJINBSUREJINBSUREJINBSUREJINBSUREJINBSUREJINBSUREJINBSURVxtOnT7F27VrExcXJthGVh562CyCqrP379yMtLa3UaR4eHqhfv/5rWadCoUC/fv003vebJCsrC9u3b0eXLl3g7Oys7XJe6fHjxxg/fjwCAgLQqFGjMtvetu0i7WJQ0ltv8eLFiI2NxbBhw0pMa9my5WsJysWLF8PQ0LDKB2VaWhrGjx+PNWvWvLWBYmxsDD8/PzRu3FhqqwrbRf8cBiVVCZaWlli7dq22y6A3EN8bVFkMSnqnxMfH49y5c3j27BlatmyJZs2aqUwPCwtDfHw8AEBXVxfW1tbo2LEjatasKc2zZcsWPHz4EHp6etIfYCMjI3z44YcAgN9++w316tWDm5tbib7T0tLg6+srte3ZswdGRkbo3bs3EhMTERkZidq1a8Pd3R0AUFhYiKioKNy5cwempqbo0qULrK2tX7mdQghERkYiMTERFhYWaN26NWxtbUvMJ9f/vXv3sGPHDgDAmTNnpGVatGiB9u3by64/LS0NJ0+ehK6uLjw8PKSwatmyJdq1awcASElJwZEjR9CvXz/UqlVLpfZ169ahdevWaNu2rUrbCwYGBrC3t0fnzp1hYGAgW8vTp0+xZcsWuLm5oVGjRrLb1bhxY2zbtg0uLi5o1apVib62b98OY2PjKn8kgVQxKOmdkJ2djY8++gjBwcHo2rUrjIyMMHnyZPTo0QO//fYbqlWrBgC4desWoqOjAQAFBQW4ceMGzp8/j8WLF+Ozzz4DAJw7dw7Z2dlQKBSIjIwEAJibm0tB+cknn+D9998vEZSrV69GZGSkSlAuWLAAtra2iI2NxdatW1GnTh3UrFkT7u7uiIqKwocffoj8/Hx06tQJDx48wPDhw7F8+XKMHz++zG39888/0bNnT2RlZcHNzQ25ubm4fPkyxowZgy+//FKa71X9Z2Zm4sKFCwCe/4Ohp6cnbatcUG7atAkTJkyAo6MjGjdujC+//BIBAQEYP348Zs+eLQXllStXMH78eISFhakEZVFREcaPH4958+apBOWL1xoAMjMzERERASEEDh48WGqovfD3c5Ry29WuXTssW7YM1tbW+P3331X6uXHjBnx9fbFo0SIG5btGEL3lOnToICwtLcWaNWtUHrt375bmGThwoLCwsBBXrlyR2u7cuSOsrKzE1KlTZftfu3at0NXVFRcvXlRZZ9euXUud39raWvj5+ZVo9/X1FQ4ODiptLVu2FHXq1BFz5syR2u7evStSUlKEhYWF6Nevn8jJyZGmrVq1Sujq6oqIiIgy6500aZKoU6eOynIFBQVi79690nN1+79165YAINasWVPm+l529epVoa+vL8aPHy+Ki4uFEEJkZ2eLIUOGCABi9uzZ0ryHDx8WAERYWJhKH8+ePRMAxLx582TXVVBQIHr16iVatGghtSUnJwsAIiAgQLZNbruWL18uAKj8voUQYvr06UKhUIiUlJRXvxBUpfD2EKoS8vPzERkZqfKIjY0FAMTFxWHPnj2YNWsWmjZtKi3z3nvvYeLEiVi7di0KCgqk9uzsbBw7dgy//vor1q5di7y8PBQXF6scptOkrKwsfPHFF9Jze3t7BAQEICMjAytXroSRkZE0bdKkSbC3t8fPP/9cZn9paWnQ09ODjo6O1Kavrw9vb2/peWX6l7NmzRoAzy92erF+ExMTjBs3rkL9/V1CQgJ2796N9evXY+PGjbCxscHly5eRmZmpkf4BYMyYMTA2NkZAQIDUlpubi40bN6J3796oU6eOxtZFbwceeqUqQe6CjReHUtPT0xEYGAghBMT//77y1NRU5ObmIjExEQ0bNsTatWvxySefoG7dumjevDlMTEykfu7fv/9aam/QoAGMjY1L1GxsbIzw8HAAkGoWQsDIyAjXrl0rsz8/Pz/s378fTk5OGDhwINzd3eHp6alybrMy/cu5fPky7OzsUL16dZX2Nm3aVKi/F/Ly8jBixAjs378frq6uqFu3LgwMDHD37l0Az3835ubmlVrHCy8Oo2/evBnfffcdzMzM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wzFlyhSkp6cjOzsbT548wddff41Jkybh4sWLlambiIjojVKhAQd69eqF+fPnS23GxsaYOnUqYmNjcfDgQbRq1UqTNRIREWlNufco8/PzyxwyztzcXOV2DyIiordduYPS1dUVu3fvRnh4uEr7uXPnsGHDBnTu3FlTtREREWldhb6Pcvr06fD09ISzszPq1KmDBw8e4MqVK/Dz8yvX12wRERG96Sp0e8g333wDHx8fHDx4ECkpKejSpQtWr16NTp06abo+IiIirarwfZStWrXiRTtERFTllfscJQCEhobiypUrKm0JCQkIDg7WSFFERERvigrdRzl16lTUr19fpd3e3h4LFixAbGysxoojIiLStnIH5bFjx+Dq6gojIyOVdj09PfTu3RuHDh3SWHFERETaVu6g1NHRQVpaWqnT0tLSUFhYWOmiiIiI3hTlDkpPT08cPXoUO3fuVGkPCQnBpk2b0KtXL40VR0REpG3lvurVwcEB3333HYYMGQJHR0c4ODjg3r17uH79OubOnSt90wcREVFVUKHbQ6ZPn46uXbti165duH//Ptq1a4d169bxPkoiIqpyNHIfZWZmJlJTU1FcXAxd3QrdcUJERPRGqlCqTZkyBZGRkQCAy5cvw8HBAY0bN0bPnj1RVFSk0QKJiIi0qdxBGR0djQsXLqBjx44AgP/+97/w8fHBzZs38eDBAxw4cEDjRRIREWlLuYPy/PnzaN26tfT8+PHjmDlzJho0aAAfHx9cunRJowUSERFpU7mD0szMDPHx8QCe713q6OigcePGAID09HSYm5trtkIiIiItKndQ9unTB5GRkejZsyd8fHwwevRoAEBxcTFCQ0P5NVtERFSllPuqVysrK0RGRmLz5s3w9vbGxx9/DAC4evUqxo4di0aNGmm8SCIiIm2p0O0hjRo1wsKFC1XamjdvjubNm2ukKCIiojcFb3okIiKSwaAkIiKSwaAkIiKSwaAkIiKSUaGgLOs7J588eYLMzMxKFURERPQmKVdQhoaGwtHREUqlEi4uLjh9+rTK9J9//rnE1bBERERvM7WDMj09HYMGDUL37t2xYcMG2NnZwcPDA1u3bn2d9REREWmV2vdRhoSEoHXr1li7di0AYNSoUVi5ciVGjx6N4uJiDB8+/LUVSUREpC1qB+Vff/1VYtQdf39/mJubY+zYsRBCaLw4IiIibVM7KJs2bVrqYdaRI0dCoVBg7NixaN++PTp06KDRAomIiLRJ7XOUbm5uePz4Ma5fv15i2ocffojAwEBERERotDgiIiJtU3uPUkdHBzt37oSeXumLDBs2DA4ODjA0NNRYcURERNpWrkHRnZ2dZae7urpWqhgiIqI3TaVH5vmf//kfzJ49WxO1EBERvXEqHZTFxcUoKirSRC1ERERvHI71SkREJKNCX9z8MkdHR1hZWWmiFiIiojdOuYLy2rVr+PzzzxEXF4d69ephwYIFGDhwYKUK2L17N0JDQ2FoaAgfHx907NhRdv67d+9ix44duHPnDuzt7TFixAjY2dlVqgYiIqKyqH3oNScnBx4eHoiPj4e7uzsePXoET09PPHjwoMIrnzZtGiZMmIBatWoBALp06YLNmzeXOX9QUBA8PT2RmpqKJk2a4OLFi2jYsCF+//33CtdAREQkR+09yuPHj8PGxgbnz5+Hnp4ehBDw8PDA3r17MWHChHKv+Nq1a1i5ciUOHz6M3r17AwCMjIwwY8YMDBkyBPr6+iWW6dSpE65duyZNmzJlCgYMGID58+cjNDS03DUQERG9itp7lHfv3kW3bt2kAQd0dHTQo0cPJCUlVWjFhw4dgqWlJXr27Cm1DRs2DA8fPkRUVFSpyzg4OJQIUCcnJzx8+LBCNRAREb2K2kFZUFAAAwMDlTalUomCgoIKrfj27duwt7eHru7/lfDee+8BAOLj49XqIyMjA9u2bYOnp2eZ8+Tn5yMrK0vlQUREpK5yXcwTHByMxMRE6fnNmzeRn5+v0vavf/0Lo0aNemVfubm5MDY2VmmrVq0aFAoFcnNzX7n8s2fPMHToUBgbG+Orr74qc74lS5ZgwYIFr+yPiIioNGrvUTo6OqJBgwbIy8uTHnXr1i3RVlhYqFZ/ZmZmyMjIUGnLzMxEUVERzM3NZZctLCzEsGHDEBcXh5CQENn5586di8zMTOmRnJysVn1ERERAOfYoBw4cWOlbQV7WvHlzrFu3Dk+fPoWRkREA4MqVKwCAZs2alblcYWEhPvzwQ8TExCA8PBwODg6y61EqlVAqlRqrm4iI3i1aG5nH29sbOjo6WLNmjdT2448/olmzZmjevDkA4OnTpxgzZgzOnDkDACgqKsLw4cMRHR2N8PBw1KtXTxulExHRO0TtPcpDhw5hy5Ytr5yvX79+GDZs2Cvnq1mzJlavXo0JEybgwIEDSE9PR3JyMg4fPizNU1BQgI0bN6Jbt25wc3PD999/j+3bt6N79+4q5yWNjIzw008/qbspREREalM7KK9du4agoCB06tSpxEU4L8vJyVF75SNGjICHhwd+//13KJVKdO/eHaamptJ0IyMjbNiwAW5ubgAAT09PbNiwoUQ/f78al4iISFPUDspOnTqhVatWuHTpEoYOHQo/Pz906NCh0gXUrl0bPj4+pU4zMDDAmDFjpOdt27ZF27ZtK71OIiIidal9jrJz586IiYnB6dOnoVQq4eXlhWbNmuF///d/ecM/ERFVWeW+mKdly5ZYsWIF7t27h7lz5yI4OBj29vY8R0hERFVSha96VSqVqFmzJmxtbQGU79wkERHR26Lc30d59+5dBAYGYsOGDSgoKMDo0aNx5coVODk5vY76iIiItErtoDx37hw+//xznDx5El5eXlixYgW8vLygUCheZ31ERERapXZQhoWFISQkBB4eHjA2NkZQUBCCgoJKzKfufZRERERvA7WD0tHREV5eXgBQYozWl6kzoDkREdHbQmtjvRIREb0NNDrW69OnT3Hjxg1NdklERKRVGgnKmJgYTJo0CbVq1VIZ5JyIiOhtV+GgTE9Px4oVK9CyZUu4u7sjNTUVP/zwAz7//HNN1kdERKRV5bqPUgiBsLAwrFu3Drt370aDBg1gY2ODnj17YtmyZa+rRiIiIq1Re48yJCQETk5OGDBgAPT19REaGorLly+jT58+r7M+IiIirVJ7j/LixYtISkrCl19+CX9/f1haWr7OuoiIiN4Iau9Rjh07Ft9++y22bt2K2rVrY+TIkTh9+vTrrI2IiEjr1A5Ka2trzJo1C9euXUNoaCj09fXh5eWF+fPnIy4uDklJSa+zTiIiIq2o0FWvrq6uWL9+vXSl68OHD1GvXj20bt0a+/bt03SNREREWqN2UKalpaGoqEilzdTUFOPHj0dUVBRiY2PRtWtXXL9+XeNFEhERaYvaQfnrr7+ibt26mDdvHu7cuVNierNmzbB8+XLMmTNHowUSERFpk9pBOXToUIwePRqBgYFwcnKCh4cHtmzZgry8vNdZHxERkVapHZR2dnZYvHgx7t69i/3798Pc3BxjxoxB7dq1MXXqVFy6dOl11klERKQV5b6YR6FQoH///tizZw9SUlLw+eefIzQ0FK1atYKLiwuOHz/+OuokIiLSikoNim5jY4NPP/0Uf/zxB0aMGIFz587h6NGjmqqNiIhI68o11uvL/j7uq4GBAT7++GP4+flpsj4iIiKtKndQ3r17F4GBgQgMDERCQgLc3NwQEBCAIUOGwMjI6HXUSEREpDVqB+W5c+fw+eefIyQkBNWrV8eoUaMwbtw4ODs7v876iIiItErtoDx9+jT09PSwY8cO6RtEiIiIqjq1g3L69On45JNPXmMpREREbx61r3rV0dF5nXUQERG9kSp1ewgREVFVx6AkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSodWgLCoqwi+//AJfX1+MHj0aBw8efOUyhYWF2LlzJ3x8fDBjxox/oEoiInqXaTUox44di4ULF6J79+5o1KgRBg0ahICAgDLnf/bsGerXr48tW7YgIyMDp0+f/gerJSKid1G5v7hZUy5cuIBNmzbh1KlT6NKli9T++eefY+zYsTA0NCyxjEKhQFRUFGrVqoVPPvkEZ86c+SdLJiKid5DW9iiPHDmCGjVqwM3NTWobPHgwMjIyEBkZWeoyurq6qFWr1j9VIhERkfaCMiEhAXXq1FH5+i57e3tpmqbk5+cjKytL5UFERKQurQVlQUEBjIyMVNqUSiUUCgUKCgo0tp4lS5bA3NxcerwIYyIiInVoLSgtLCzw+PFjlbaMjAwUFRXBwsJCY+uZO3cuMjMzpUdycrLG+iYioqpPa0HZsmVL3LlzB9nZ2VLbxYsXpWmaolQqYWZmpvIgIiJSl9aC0tvbG4aGhli+fDkAoLi4GMuWLUO7du3QqFEjAEBOTg769++P0NBQbZVJRETvOK3dHmJlZYXNmzdjxIgR2L17NzIzMwEAhw4dkuZ59uwZDh48iMGDB0ttU6ZMQVJSEq5du4bHjx+jf//+AICdO3eWeksJERFRZWgtKAGgf//+SE5ORkxMDJRKJdq1awd9fX1puomJCYKDg9GqVSupzdfXt9QrV19ejoiISFO0GpQAYGpqiu7du5c6TU9PT9pjfMHd3f2fKIuIiAgAB0UnIiKSxaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSwaAkIiKSodWgzMvLw+LFi+Hp6Yl+/frh119/fS3LEBERVZSeNlc+dOhQXL9+Hd988w3S09MxZcoU3Lt3D3PmzNHoMkRERBWltaCMiIjAvn37cPbsWbi4uAAAcnJy8MUXX2Dq1KkwNjbWyDJERESVobVDr6GhobC1tZUCDwC8vb2Rk5ODyMhIjS1DRERUGVrbo0xKSkLt2rVV2urUqSNN09Qy+fn5yM/Pl55nZmYCALKysipW+EuK859Wug8idWniPfu6LFmyRNsl0Dtm7ty5le7jxWdKCCE7n9aC8tmzZ1AqlSpt+vr60NXVxbNnzzS2zJIlS7BgwYIS7fb29hWsnEg7zJdruwKiN8fSpUs11ld2djbMzc3LnK61oLSyssLjx49V2jIyMlBcXAxra2uNLTN37lzMnDlTel5cXIzHjx/D2toaOjo6ldwKKq+srCzY29sjOTkZZmZm2i6HSKv4edAuIQSys7NLHKn8O60FZZs2bbBixQqkp6fD0tISABAVFQUAaN26tcaWUSqVJfZCLSwsNLEJVAlmZmb8w0D0//HzoD1ye5IvaO1iHm9vb1hYWOCbb74BABQUFGDp0qXo2rUrHB0dAQBPnjxBx44dcfDgQbWXISIi0iStBaWpqSl27dqFLVu2wN7eHra2tsjMzFQZQKCwsBBRUVF4+PCh2ssQERFpklYHHHB3d8fdu3dx/fp1KJVKNGzYUGW6qakpIiIiVPYWX7UMvdmUSiW+/PLLEofDid5F/Dy8HXTEq66LJSIieodxUHQiIiIZDEoiIiIZDMp3xI4dO3Dv3r1/dJ13797Fvn37sGfPnn90vUTl8eDBAwQFBb329Zw/fx47d+6UbmmjtweD8h0xcuRInD9//h9b36FDh9C0aVOsX78ehw8f1mjf27dvx/379zXaJ727YmNjMWzYsNe6junTp2PAgAHYvn27Rj+H9+/fx/bt2zXWH5WOF/O8IwwNDbFz507079//H1mfj48PbGxssGrVKo33raenhwMHDqBPnz4a75vePVeuXMGiRYte216lEALGxsbYtWsXvLy8NNr3kSNH0L9/fxQWFmq0X1Kl1dtDSLOKi4tx9uxZpKWloUmTJq8chOHx48eIiIgAAHTq1AlWVlbStMjISCgUCrRr1w7A88Oof/zxB3r06IHq1asDAE6ePAlLS0u0aNFCpd9du3bh2rVrKCoqQlBQEBo2bIg2bdoAAHJzcxEREYHc3Fy0aNGixJi7wcHByMnJga6uLuzs7NC6dWtUq1ZNmr5v3z4IIXDy5ElkZGTA2NgYPXv2xN69e+Hl5aUyysbu3bvRvn172NnZQQiBbdu2wcPDA1lZWYiNjUWjRo3QuHFjAEBqaipiYmJgamqKNm3acJSUt8Cff/6JyMhIfPDBB7hy5QqSkpLQpEkT1K9fv8S8N2/exNWrV2FlZQVXV1fo6+tL02rUqIH3339fZf7s7GxER0ejqKgILi4uKp8N4Pme3NmzZ2FiYoI2bdqUObpLWloaDh8+jNzcXJw7dw6ZmZno1q0bbG1tX9lPZmamdDRGqVTC0dFR5bOWnp6OkydPQgghhXzDhg1hYWGBq1evYsCAAdK8jx49QkhICIYMGQJdXV2V1y4qKgrJycno1auXtP7Y2FjEx8fD3t4erVq1gkKhUNmupKQkXLlyBdbW1mjTpg0MDAxK/yVVFYKqhIyMDNG6dWvh5OQkvL29hbOzs5gwYYI0XalUiuDgYOn5tm3bhLGxsXB1dRWurq7C2NhYbN++XZq+aNEi0alTJ+n5F198IQCI1atXS2329vbi119/LVHL6NGjRY0aNUSTJk2Er6+vWL9+vRBCiPDwcFGzZk3RsWNH0bdvX2FhYSG++OILlWX9/f2Fr6+v8PHxEc2bNxd169YVly9flqZPmDBB6OjoiK5duwpfX1/h7+8vUlNTBQARGxur0pe5ubnYsWOHEEKIZ8+eCQDCy8tL1K9fXwwaNEjs27dPCCHE119/LczNzUXv3r1Fly5dRI0aNcTx48fVf/FJK/bs2SOUSqXo1auX6Nixo+jVq5cwMDAQv/zyi8p8EydOFCYmJqJXr17C0dFRODk5ifj4eGn68ePHxct/Cn///XdhaWkpOnXqJPr27Svq1asngoKCpOmLFy+W3i/u7u6ievXq4siRI6XWePXqVTFo0CABQPTo0UP4+vqKixcvqtVPcnKy8PX1Fb6+vsLb21vY2tqKHj16iLy8PCGEEPHx8aJr165CR0dHmm/9+vVizZo1wsHBQaWO06dPCwAiNzdX5bXr0aOHaNeunfD19RXJyckiKytL9OnTR9jZ2YkBAwYIZ2dn4eLiIlJTU6W+Fi5cKExNTYWXl5fo0qWLaNmypbh9+3Z5fnVvHQZlFbF27Vrh6OgoCgoKpLbdu3dLP78clI8ePRIWFhZi2bJl0vSlS5cKCwsL8ejRIyGEEGfOnBH6+vriyZMnQggh3NzchIuLixg2bJgQQojbt28LAOLu3bul1tO1a1cxb9486XlGRoawsrISW7duldru3LkjTE1NRVhYWJnbNXXqVNGjRw+VNoVCIQ4fPiw9L09Quru7S39ohBBi//79wsbGRiQlJUlta9euFba2ttIfFXoz7dmzRwAQy5cvl9qWL18urKyspOd79+4VBgYG0j9b+fn5wtPTU3h5eUnz/D0o33//fTF58mTpeU5OjhRghw4dEtWrVxcJCQnS9MDAQGFjYyNycnJKrTM7O1sAEBEREVJbRfrJyckRTZs2Vdnew4cPC4VCoTKfukEJQCxcuFBlvo8//lh4eXmJ/Px8IYQQRUVF4oMPPhDDhw8XQghRUFAgDAwMxLFjx6Rlbt68WeKzV9Xw0GsVUa1aNWRnZyMhIUEarWjgwIGlznvkyBEUFhZi2rRpUtuMGTOwcOFCHD16FB9++CHat28PfX19nDlzBu7u7jh79ix27tyJ8ePHAwDCw8NRv359tb+ubN++fcjPz4eenh527NgB4Pm5GwcHB4SFhaFbt27SvPHx8bh58yYyMzNhZmaG6OjoirwkpZowYYLKKCgbNmxA06ZNER0djaioKAghoKenh/v37+P69etlDrZPbwZdXV1MmDBBet6tWzd88sknePToEapXr46goCD0798fzZs3BwAYGBjgs88+Q58+fZCVlVXqIfZq1aohMTFRmm5kZITevXsDeP5+adKkCWJiYnD27FkIIaBQKJCWloarV69KpypeRd1+iouLcf78eaSkpCAvLw9169bV6Ofh5b8BhYWF+O233zB58mTs378f4vmOFOzs7LBz504Az19vpVKJ2NhYeHp6QldXFw0aNNBYPW8qBmUVMWTIEJw+fRpt2rTBe++9hx49emDSpEmlDvGXlJQEOzs7lfM0BgYGsLOzk74AW19fH66urggLC4Oenh7q1auHvn37Ii8vD3FxcQgPD1cJt1dJTEyEnp6e9IF7oWnTpqhbty6A538URowYgQMHDqB9+/bS16plZWUhPz9fI8N81apVq0Rd+fn5Jery9fWFri4vCn/TVatWDYaGhtLzF++RvLw8AM/f6507d1ZZ5sW5+6SkJClAX/bNN9/Az88Ptra2aN++Pfr27YvJkyfDxMQEiYmJePLkSanvFz099f+cqtNPSkoKevTogdzcXDRr1gympqZITEws87t3y8vIyEjlH4W0tDQ8ffoUFy9eRHJyssq8Lz7rCoUCmzZtwsyZM7FkyRJ07doVQ4cOxeDBgzVS05uKQVlF6OnpISAgAMuXL0dUVBTWrl2Ltm3b4saNGyW+a6169eolvtcTeH5xz4sLdYDnH47g4GDo6+uje/fu0NXVhZubG8LDw3Hy5EnpW1zUYWZmBiEEtm7dWub3gB45cgTBwcGIj4+HjY0NAGDv3r0IDQ2V/QbyF4FWXFwstQkhkJ+fX2Lev6/bzMwMdevW5cD6VVRp7/UXz19+r7/svffew4kTJ/DXX38hPDwcixcvxoEDB3Dq1CmYmZnByckJW7ZsqVRd6vSzePFi2NnZ4fjx49L7dty4cUhMTJTtW1dXV+WzAPzfPw4v+/tnwdTUFDo6Ohg/fjyGDBlSZv/e3t7w9vbGrVu3cPjwYYwbNw53795V+d7fqob/MlcRLwYTUCqVcHd3x5o1a5CTk4MrV66UmLdz5854+PAhfv/9d6nt5MmTePToEVxdXaW2bt264dy5czhw4ID0H2W3bt2wbt06JCcnl2uPslevXsjOzsbWrVtV2vPz8/HXX38BeH4FoJWVlRSSAEr8xw0AJiYmKh/86tWrQ6lU4vbt21JbZGRkqX8c/q5Pnz7Yt28fUlNTVdr//PNP9TaM3mhubm44dOiQynthx44dcHR0LHF04YUXv3tra2sMGjQIX3zxBaKjoyGEQJ8+fRAcHFzi/VHe94s6/dy/fx8NGzaUAi0nJwdHjhxRmd/ExARFRUUqe5l16tTBgwcPkJ2dLbWFhYW9siZTU1O4urpi9erVJf4xfVFXbm4u0tPTAQANGjTAtGnT4O3tjcjISHU2+63FPcoqYt++fQgMDMT777+PWrVqYf/+/bC3t0f79u1LzNu0aVNMnDgRAwcOxL///W8AwLfffotJkyahadOm0nzt27eHgYEBLl68qBKUn376abnOTwJAkyZN8NVXX2Hs2LGIjo5G8+bNkZCQgF27diEwMBDW1tbo0aMHpk2bhjFjxqBLly4IDQ0tdbACFxcXrFy5Ek+ePIG5uTkGDBiA4cOHY+bMmdIfiA0bNqgcWi7L9OnTceDAAXTo0AGTJ0+GlZUVzp8/j1OnTuHatWtqbx+9maZOnYr169eje/fuGD16NK5fv46AgADs3r27zGUmT54MQ0NDdO3aFQCwatUqDBo0CDo6OvD390dwcDA6dOiAKVOmwNraGhcuXEBoaChu3rypdl3q9PP+++9j4sSJqFOnDiwtLbF27Vrk5OSo9NOkSRMYGRlh9uzZaN++PRo2bIiuXbuiVq1aGDx4MHx9fXHp0qVS/+EszU8//QRPT094eHjAx8cHeXl5CA0NRf369bFixQpkZ2fD1dUV//rXv9C8eXOkpKRg165dWL9+vdrb/jbiHmUVMWnSJKxatQpZWVk4ffo0OnbsiJiYGFhYWAB4fg6zTp060vyrVq3Cjz/+iLi4ONy4cQMrV67EypUrVfrU19fHp59+Cn9/f9SsWRMA0Lp1a4wYMQLTp0+XrcfDw6PE/ZXz58/HiRMnoFAocObMGRgZGeHo0aPo0KEDAKBu3bqIioqCpaUlTp06hTZt2uDYsWPw9fVVuY9r06ZN6NSpE44ePYpjx44BAH7++WfMmjUL586dQ25uLo4dO4aRI0dKYa6rqwtfX1+VvVXg+TmusLAwLF68GAkJCTh//jxcXFz+0VGMqGLs7OxKnBszMzODr68vjIyMADz//UZFReGDDz5AREQEdHV18ccff6gMvGFrawtfX1/p+Z49ezB48GBcvXoVV65cwZw5cxAYGAjg+cAdoaGh+Pbbb5GYmIjz58+jdevWuHTpUpl16uvrw9fXV+VQrzr9jBo1Cps3b0ZSUhIuXbqE2bNnY9WqVejevbs0j5WVFUJCQlBYWIh9+/bh0qVLMDQ0REREBFxcXBAREYGGDRvi8OHDKp+j0l47AGjRogWuXr0KLy8vREdH4969e5g2bRpWrFgBALCxsUFMTAzq1q2LM2fOIDMzE8eOHZM9VFsVcGQeIiIiGdyjJCIiksGgJCIiksGgJCIiksGgJCIiksGgJCIiksGgJCIiksGgJCIiksGgJCIiksGgJCIiksGgJCIiksGgJCIiksGgJCIikvH/AITkD1LZLAVTAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from autosampler.spaces.feature_selection import (\n", + " vamp2_score, rank_candidates, greedy_vamp_selection)\n", + "\n", + "def slow_plus_noise(n=8000, p=0.02, n_noise=4, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " s, states = 0, np.empty(n, int)\n", + " for i in range(n):\n", + " if rng.random() < p: s = 1 - s\n", + " states[i] = s\n", + " slow = np.where(states==0, -1.0, 1.0) + rng.normal(scale=0.1, size=n)\n", + " return np.column_stack([slow, rng.normal(size=(n, n_noise))])\n", + "\n", + "traj = slow_plus_noise()\n", + "ranked = rank_candidates(\n", + " {\"slow feature\": [traj[:, [0]]], \"noise features\": [traj[:, 1:]]}, lagtime=10)\n", + "print(\"VAMP-2 ranking:\", [(n, round(s,3)) for n,s in ranked])\n", + "cols = greedy_vamp_selection([traj], lagtime=10, min_gain=1e-3)\n", + "print(\"greedy selection keeps columns:\", cols)\n", + "\n", + "fig, ax = plt.subplots(figsize=(5,3))\n", + "ax.bar([n for n,_ in ranked], [s for _,s in ranked], color=[\"C0\",\"C7\"])\n", + "ax.set_ylabel(\"VAMP-2 score\"); ax.set_title(\"Feature set quality\"); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "41851db6", + "metadata": {}, + "source": [ + "## 3. MSM estimation\n", + "\n", + "We generate a 3-state metastable chain, build an MSM, and inspect the implied\n", + "timescales and metastable free energies." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "7a24f604", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-16T13:51:16.013775Z", + "iopub.status.busy": "2026-06-16T13:51:16.013524Z", + "iopub.status.idle": "2026-06-16T13:52:27.511698Z", + "shell.execute_reply": "2026-06-16T13:52:27.509457Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSM(lag=5, active_states=50/50, t2..=50.8, 16.6, VAMP2=2.429)\n", + "stationary distribution: [0.03 0.018 0.032 0.033 0.02 0.026 0.026 0.021 0.032 0.003 0.015 0.01\n", + " 0.014 0.011 0.004 0.037 0.027 0.022 0.01 0.029 0.027 0.031 0.002 0.022\n", + " 0.006 0.002 0.016 0.023 0.035 0.022 0.01 0.005 0.027 0.03 0.037 0.029\n", + " 0.004 0.02 0.029 0.028 0.029 0.006 0.033 0.015 0.017 0.011 0.008 0.025\n", + " 0.029 0.002]\n" + ] + } + ], + "source": [ + "from autosampler.msm import MSMEstimator\n", + "\n", + "def three_state(n=40000, p=0.02, seed=0):\n", + " rng = np.random.default_rng(seed)\n", + " centers = np.array([-2.0, 0.0, 2.0])\n", + " P = np.array([[1-p, p, 0],[p, 1-2*p, p],[0, p, 1-p]])\n", + " s, states = 0, np.empty(n, int)\n", + " for i in range(n):\n", + " s = rng.choice(3, p=P[s]); states[i] = s\n", + " return (centers[states] + rng.normal(scale=0.15, size=n)).reshape(-1,1)\n", + "\n", + "est = MSMEstimator(lagtime=5, n_microstates=50, n_metastable=3,\n", + " n_timescales=2, lagtimes=[1,2,5,10,20])\n", + "result = est.fit([three_state()])\n", + "print(result.summary())\n", + "print(\"stationary distribution:\", np.round(result.stationary_distribution, 3))\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "72dddc33", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-16T13:52:27.514496Z", + "iopub.status.busy": "2026-06-16T13:52:27.514044Z", + "iopub.status.idle": "2026-06-16T13:52:28.106859Z", + "shell.execute_reply": "2026-06-16T13:52:28.105418Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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2RkeHh6QSpnDExEREREREWUrVNJ97949/Pjjj/Dx8UFCQoLGsqpVq2LEiBGYOXMm3N3d9RokERERERERUVmk9anpqVOnokOHDkhPT8eWLVsQHByM1NRUpKSkIDg4GJs2bUJKSgrat2+PadOmFWfMRERERERERGWC1me6rays8OzZM9jY2ORYVrduXdStWxcjRoxAXFwcVq1apdcgiYiIiIiIiMoirZNubRNpGxsbJt1ERERERERE0HH0coVCgXPnzqmfX716FRMmTMCyZcuQkZGht+CIiIiIiIiIyjKdku7ly5fjypUrAIDk5GT069cP0dHR2L59OxYsWKDXAImIiKhkvXz5Ev7+/nj69KnWbZKSknD27Fk8efJEr/0SERGVdTol3Tt37sQHH3wAADh16hRcXV1x/PhxHDlyBLt379ZrgERERFRyli9fjtq1a2PmzJlo3rw5Bg0aBLlcnmf9qKgoTJ06FQ0bNkT//v2xdetWvfRLRERUXuiUdMfExMDa2hoAcO7cOfTp0wcAUKdOHcTGxuovOiIiIioxZ8+exddff41jx47h5s2bePDgAa5du4ZvvvkmzzYRERHw8PDAo0ePULduXb31S0REVF7olHQ3adIEP/zwA27evIk9e/agV69eAICgoCA0btxYrwESERFRydi2bRs6dOiAbt26AQAcHR0xYcIEbN++Pc82rVq1wrRp02BpaanXfomIiMoLnZLuNWvW4Mcff0SrVq3Qt29ftGvXDgDwww8/YPbs2fqMr9h4e3vD3d0dbdq0MXQoREREpcKtW7fQsmVLjbKWLVsiLCwMcXFxJdqvXC5HYmKixoOIiKgs0nrKsNd17twZMTExSE5OhpWVlbp80aJFaNCggd6CK05eXl7w8vJCYmKi+lJ5IiKiiiw+Ph42NjYaZdWrV89zWXH2u2LFCixZskSn9REREZUmOp3pBgCpVKqRcANAw4YNIZFIihwUERERlTxTU1OkpaVplKWmpqqXlWS/8+fPR0JCgvoRFham8/qJiIgMSaekW6VSYcOGDWjRooXGWeK5c+ciPDxcb8ERERFRyalTp06O43h4eDhMTU3h4OBQov2amZnByspK40FERFQW6ZR0r1u3DmvXrsXHH3+scY+Vm5sbli1bprfgiIiIqOS8++67OHXqlMZZ6QMHDqB79+4wNs66Iy0mJganT58u1HRf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pKena5SlpaUBgEG24U2//PILPDw80K5du3zrvbl84MCB6N27N/bs2VNqku7c9jUApKen57mvdWlTkjZs2IA1a9Zg//79aNCgQZ71nJycNEb5btasGaZNm4adO3caLOl+88xD7969MXDgQOzZsyfPpLssvF/69+9f4Iip7733nsbz2bNnY9OmTfD19S3WpPvVq1d477330KhRI2zYsEFdnj0qaW77Nr/3RmHbVHQRERGYN29evnXef/99zJw5E4Buf++1a9dG7dq11c9btGiByZMnY8+ePWUi6ebfoqa8jkEFbdtHH32k/t3W1hbffPMNunXrhqdPn+Z7rCiPSvtxo6QNGTJE/buxsTFWrVqFLVu24MiRI5gxY4YBI9OfM2fOYPz48Vi1ahX69OmTZz1d319ljZeXFy5cuIC///4736tW27Rpo/G8X79+6NevH/bs2cOku7w5fPgwRo4ciR9//BGffPJJodvb29sjMzMTMTExJTKNkDbs7e0RERGR53IXFxc8efJEoyy7/usfnAxBJpNh//79WL16tU7t7e3tcfv2bT1HpTsXFxdERUVBpVKpv+mPiYlBRkZGnvtalzYl5aeffsJnn30GX19f9Rnswijob9MQ7O3t872twsXFJUfMERERMDEx0XpqkOJy9+5d/PPPPzh27JhO7Yv79YiNjUWPHj1gY2MDPz8/VKpUSb0s+4x7RESExkE3IiIi3/dGYdtUdI0aNYK/v7/W9V1cXHD37l2NMl2OD6XxvZ4XOzs7WFhY5Po+z2ubdWlTVri4uCA1NRUymUw9NWT255zC/g0AWfukoiXdeR03gKwrrio6Y2Nj2NjYlJn/EQU5e/YsBgwYgEWLFuGLL77It66Li4v6M132l3eJiYlITk4u8/87ss2YMQM+Pj44c+YMmjRpUuj29vb2uHbtWqHaVKxracogPz8/vP/++/j+++8xderUHMtVKhX8/f0RHR0NIOserjedOnUKNjY2GvfflqTcYvrrr780/siTk5Ph7++PxMREAEDPnj1x7do1xMTEqOscOnQIDRo0MPg8kr///jsAYOzYsTmWvXz5Ev7+/sjMzASQc9vlcjkuXLig0xu8uPTs2ROJiYkaty0cOnQIpqam6Ny5s7rM398fL168KFSbkrZ582bMnj0bPj4+GDBgQI7lqamp8Pf3R0JCAgDt/jZL2psxZWZm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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from autosampler.analysis import plots\n", + "fig, axes = plt.subplots(1, 2, figsize=(10,3.5))\n", + "plots.plot_implied_timescales(result.its.lagtimes, result.its.timescales, ax=axes[0])\n", + "plots.plot_metastable_free_energy(result.metastable_populations, ax=axes[1])\n", + "plt.tight_layout(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "9a875395", + "metadata": {}, + "source": [ + "## 4. Convergence detection\n", + "\n", + "A `ConvergenceMonitor` combines criteria (here: implied-timescale stability) and\n", + "reports convergence once they hold for `patience` consecutive iterations." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "ab3a348b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-16T13:52:28.109944Z", + "iopub.status.busy": "2026-06-16T13:52:28.109606Z", + "iopub.status.idle": "2026-06-16T13:52:28.118296Z", + "shell.execute_reply": "2026-06-16T13:52:28.116815Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "timescale= 100.0 -> converged=False\n", + "timescale= 102.0 -> converged=False\n", + "timescale= 101.5 -> converged=True\n", + "timescale= 101.7 -> converged=True\n" + ] + } + ], + "source": [ + "from autosampler.msm import ConvergenceMonitor, ImpliedTimescaleCriterion, MSMResult\n", + "\n", + "def fake(ts):\n", + " return MSMResult(lagtime=5, n_microstates=10, n_states_active=3,\n", + " timescales=np.array([ts]), stationary_distribution=np.ones(3)/3,\n", + " transition_matrix=np.eye(3), cluster_centers=np.zeros((3,1)),\n", + " counts_per_state=np.ones(3))\n", + "\n", + "mon = ConvergenceMonitor([ImpliedTimescaleCriterion(tol=0.05, n_timescales=1)],\n", + " mode=\"all\", patience=2)\n", + "for ts in [100, 102, 101.5, 101.7]:\n", + " print(f\"timescale={ts:6.1f} -> converged={mon.update(fake(ts))}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "3ded3a93", + "metadata": {}, + "source": [ + "## 5. Weighted-ensemble resampling\n", + "\n", + "Split/merge resampling keeps a target walker count per bin while **conserving\n", + "total statistical weight**. Below, an under-populated bin (1 walker) is split\n", + "and an over-populated bin (4 walkers) is merged." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "5035b6d9", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-16T13:52:28.120966Z", + "iopub.status.busy": "2026-06-16T13:52:28.120723Z", + "iopub.status.idle": "2026-06-16T13:52:28.274278Z", + "shell.execute_reply": "2026-06-16T13:52:28.272927Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "input total weight : 2.0\n", + "output total weight: 2.0\n", + "walkers per bin : [3 3]\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from autosampler.binning.we import WeightedEnsemble\n", + "we = WeightedEnsemble(target_per_bin=3)\n", + "weights = np.array([1.0, 0.1, 0.1, 0.1, 0.7])\n", + "labels = np.array([0, 1, 1, 1, 1]) # bin 0: 1 walker, bin 1: 4 walkers\n", + "res = we.resample(weights, labels, rng=np.random.default_rng(0))\n", + "print(\"input total weight :\", weights.sum())\n", + "print(\"output total weight:\", round(sum(res.weights), 6))\n", + "print(\"walkers per bin :\", np.bincount(labels[np.array(res.parents)]))\n", + "\n", + "fig, ax = plt.subplots(figsize=(5,3))\n", + "ax.bar(range(len(weights)), weights, alpha=.5, label=\"before\")\n", + "ax.bar(range(len(res.weights)), res.weights, alpha=.5, label=\"after\")\n", + "ax.set_xlabel(\"walker\"); ax.set_ylabel(\"weight\"); ax.legend(); plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "e73a50d0", + "metadata": {}, + "source": [ + "## 6. The analysis report\n", + "\n", + "During a real run each iteration writes an `iter_*/msm.npz`. `autosampler-analyze`\n", + "(or `plots.plot_convergence_report`) turns these into a multi-panel summary.\n", + "Here we synthesise a small run directory and render the report inline." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "edc3acd0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-16T13:52:28.276746Z", + "iopub.status.busy": "2026-06-16T13:52:28.276488Z", + "iopub.status.idle": "2026-06-16T13:52:29.199908Z", + "shell.execute_reply": "2026-06-16T13:52:29.198197Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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GgAAADgKiRwAAAAAQJU4M/ng5OTkwEguTGd+ZiRyAAAA6i4SOQAAAAAAAAAAADUUiRwAAAAAAAAAAIAaytnRAQAAAAAAYK+4lCx9semoNhxKUGqWVd5uzrqseYCGdG2kIAv78QAAAKD2IJEDAAAAALhgZObkasaqnVq25bhycovuG7NmX7zm/bJXg7s00rQB7eTuUj378sTGxuree+/V2LFj1b9//2LHT548qS+++ELbtm1TWlqaWrZsqZEjR6pFixbVEh8AAAAubLUykZOXl6c///xTK1eu1K5du3Ts2DG5ubmpQ4cOuuOOO9SnT5/zvsbff/+twYMHyzAM9e/fXwsWLKiEyAEAAADHsMbHK2nZMqVv3KS8tDSZvbzk2a2b/AYPknNgoKPDAyTlJ3FGLtyoDYcSSj0nJ9fQ4o1HdTAuVR/f261akjnp6en69ttvddNNNxU79t1332ngwIEKDw/XAw88IF9fX61cuVJt27bVvHnzNGbMmCqPDwAAoLaqK6u0a2UiZ/jw4friiy+KtW/dulWffPKJRo4cqYULF8psrtgWQbm5uRo1apT2798vSYqKijqveAEAAABHycvMVMwLM5W0YoVktRY5lvbXX4p7/XX5DRyo4CmTZXarPRMhXJhmrNpZZhLnTBsOJWjGql16cWDHKo6qbHFxcZo0aZKee+45OTnlJ5VGjBih2267TRMmTNAtt9yihg0bOjRGAACAC01NXKVdlSqWyajhcnJydNVVV2nOnDn67rvv9M8//2j16tW66667JEkff/yx3n777QqPP3/+fG3evFlDhw6trJABAACAapeXmalj9z+gpKVLiyVxbKxWJS1ZomOj7ldeZmb1BgicITYlU8u2HLerz7ItxxSXklVFEeXbsWOH7rvvPknSm2++qZtuukk33XST3nvvPUnSoEGDNHPmTFsSp9D111+vnJwcbd68uUrjAwAAqG0KV2kv3nisWBKnUOEq7ZELNyozJ7eaI6x8tXJFzueffy63Et4WvPbaa+Xu7q53331XS5YsqdAS9sOHD2vq1Km655571KNHD/33v/+tjJABAACAahfzwkylb9pUrnPTN21SzMwXFfrsjCqOCnWFYRhKziwlgViCT/46UupEvTQ5uYY+WXdYo65sXu4+Pu7OMplM5T6/QYMGGjFihH799VddeeWVtj1ymjfPv6a3t3eJ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yGw267vvvpOHh4eysrLUpk0brVu3TpIUHByszZs3czc9AAAAAKdx6B05l97cHD58ON9zqlSpou+++05eXl569NFHtWfPnkLNYLPZNHDgQL3++uu67777tHLlSnl6ehbqcwAAip+s8+cV/XJ/nXpz0j8ljqurQkaMUPi0qZQ4AFAKff7559q4caPq1KkjDw8PSdKSJUu0bt06Pfnkk7rtttt06tQpjR8/3uCkAAAAAEoThxY5bdu2lSStWbPmqufVq1dPK1eulMlk0l9//VVoz5+ZmalevXpp+vTpeuihhyhxAACSpLQ//9ThLl10fu1a+8y1fHlV+myeyvR8UiaTycB0AACj/PDDD5Kke++91z5bvny52rZtq3nz5tlXEFi4cKFsNpshGQEAAACUPg4tcmrXrq1atWpp7969+vPPP696btOmTbV06VK5ubkVynOnpqaqU6dOmjdvnrp06aKlS5fK3d29UB4bAFB8JS5ZqiPd/k8ZR4/ZZ97NmqnKsqXyatDAwGQAAKMdPXpUklS5cmX7bPv27br//vslSZUqVVJERIRSUlJ0+vRpIyICAAAAKIUcukeOlL0njNVqlYuLyzXPbdu2rY4dO6bU1FQFBwff8HMmJSWpQ4cO2rBhg5588kl9+umn13z+5ORkDR48WJI0ZcoUeXt73/DzAwCKHmtamk6+/rqSliz9Z2gyKeilFxX03HMyFeDvKQBAyebqmv32KC0tTZKUmJioQ4cOqX79+vZzLn3w7MKFCzf1ngUAAAAACsrhRY7ZbJbZXPAbf0JCQm76Obt3764NGzbIy8tLHh4eevHFF3Od4+XlpalTp9r/nJqaqg8//FCSNH78+FxFzrvvvqvIyEhJUlRUlCRp7969eu655+znvPXWW/Lz87vp/ACAwpV+5IhO9B+gi/v322cugYEKm/y2fJo1MzAZAKAoqVixoiRp48aNat68ub777ju5uLioSZMmkrL334yOjpbZbFZERISRUQEAAACUIg4vcowQFxcnSUpJSdHHH3+c5zn+/v45ipxr+fbbb/Xdd9/lmB0/ftxe/kjSmDFjKHIAoIg5t2aNYkeMlPXCBfvM8/bbFT59mizlyxuYDABQ1HTt2lVffPGFxo0bpwMHDmjlypW677777N/j//nnn7p48aLq1q1baEtCAwAAAMC1GFrk9O3bV99++60++ugjPfDA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FztAYbvw88+KGfqKspKS7DOPW29V+Izpcvt7mT8AAACUbGlpafq///s/JSUlqUmTJlq+fLlCQ0Ptx48ePapDhw7luOa7777TihUr5O3trZ9//lkNGzaUlH2XTrt27bRx40aNHj1a8+bNc+prAQAAAHB9WBT5OoWFhal58+Zq2LChLBaLjhw5oj/++EOS1KRJE4PToSSxZWXp1IwZOv7vvjlKnICuXVXpyy8ocQAAAEqRL774QgcPHpS3t7eWLVuWo8SRpEqVKqlVq1Y5ZpcKmn79+tlLHEny9vbWzJkzJUmLFy9WymV7LwIAAAAoeihybtKECRNks9l0yy23sL40Ck1mQoKO9emjhPc/sM9MHh4Km/SmQseNldnd3cB0AAAAcLYlS5ZIkrp3766wsLACXbNx40ZJ0iOPPJLrWP369VWlShWlpqZq9+7dhRcUAAAAQKGjyPnbuXPndN999+m+++7TuXPnchyzWq16/fXXlZiYaJ+lp6drzJgxmjVrliTp9ddfl8lkcmZklFApO3fqcMdOStmy1T5zq1xZlRcukH8eb8IBAABQ8u3YsUOS1K5dO0nS5s2bNWfOHC1ZskTHjx/Pdf7Fixd17NgxSVLt2rXzfMxbb71VkvTXX385IjIAAACAQlJi98gZPny4fvnlF0lSXFycpOw3P/fdd5/9nEWLFikwMFBSdjHzww8/2P/5clarVa+99ppef/111ahRQwEBAYqMjFTS38tdDRo0SF27dnX4a0LJZrPZdObTOTo1ZYqUlWWf+7Zvp9DXx8vFx9vAdAAAADCKzWZTQkKCJCkoKEjNmzfXpk2b7MdNJpO6du2qDz74QAEBAZJk/3Cai4uL/P3983zcMmXK5Dj3aurUqZPnPCoqStWqVSvwawEAAABw/UpskbN79257MXNJQkJCjtnFixcL9FguLi76z3/+o/fee0979+61z2vUqKHRo0frySefLJzQKLWyzp9X7IgROr/2+3+GFotCXn1VgT26c7cXAABAKWa1WpX19wd9XnrpJf35559q06aNQkNDdejQIW3cuFELFixQXFyc1q1bJym7/JF01e8jzebsBRqyLvsQEQAAAICip8QWOW+++aaGDBly1XMufQJNkvz9/bV27Vr7P1/OZDJpzJgxGjVqlKKionT69GmFh4erSpUqhR8cpU7avn060X+AMv5e+kKSXEND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e9tFixZp/vz5PM8q5y5cuKB7771XnTp10v333+/pcOABFotFw4cP1y+//KLU1FTbvcybb76p8PBw/fnnn3rggQc8HabTkMjxURUqVJDk+AbnwoULtnIWi8VtcQHwrI8//li33367GjZsqBUrVig8PNzTIcFNWrVqpcTERJ04cUIZGRlasWKFLrvsMs2YMUP33nuvp8ODC7377rtatWqVZsyYocqVK3s6HHjIm2++qd69e9s+xVahQgXdeuutWrlypSpWrKjt27drw4YNHo4SMH8fU7FiRbfFBMDz/vWvf+npp59W165dtWjRIgUEsJKAr7j99tuVmJiopKQkpaen67PPPlPFihX15JNP6rXXXvN0eHChp59+WkeOHNF7771n+zAifEtwcLDmzJmjzp07KzQ0VJJUtWpV3Xffffrqq68k5X5Y+ezZs54M02m4yn1U3lQEhw4dsrs/b3tkZKTbYgLgWW+99ZbuvPNONW7cWCtXrmRYsg8LCAhQt27dtHTpUgUEBGjOnDk6c+aMp8OCCxw/flyPP/64evXqpQ4dOigxMdH2lfdmNyMjQ4mJieVqSDrMq1u3rm2KzV27dnk4GoD7GAAFGYahhx56SJMnT9a1116r7777rlyth4CSCQ4O1q233qp58+ZJkqZPn+7ZgOAymzZt0htvvKExY8YoPDy8wH1MamqqpNwPdyQmJnIv66Ouvvpq1a1bV9nZ2dq7d6+nw3EKPqLgo1q2bClJWrt2rQzDKDTq5pdffilQDkD59uyzz+r//u//1KJFCy1fvly1atXydEjwAhEREapfv77279+vhIQEVatWzdMhwcl+//13JScna9myZQ7Xm1i+fLlq166tunXr6vDhw26OEN4gb+QDn26GN8i7P/n111/t7uc+BvAd2dnZGjlypObOnasbbrhBn3/+uUJCQjwdFrxAu3btJEmHDx9WdnY272HKoV9//VU5OTmaMWOGZsyYYbfMzJkzNXPmTPXo0UPLly93c4TwNMMwbNPEl5e/AeXjKFBinTp1Uo0aNXTkyBEtXLhQAwcOtO1LT0/X22+/LUnq16+fp0IE4AaGYWjcuHF644031K5dOy1dupTp1HyM1Wp1OAx9+/btOnjwoCwWi23xaJQvwcHBDhO36enpOnv2rIKDgxUWFsaCweVYUX8HNm/ebHtg3qpVK3eGBdjVt29fWSwW/fDDD9qxY4diY2Nt+44cOaIFCxZI4j4GKO/S0tI0cOBALV68WDfffLPmz5/vcO0slE9FvX/59ttvJeWOziwvD3BRUMWKFR3ex6SkpCg1NVUVKlRQ5cqVbesnofwp6u/AJ598olOnTik0NFRNmzZ1c2SuwV8zHxUQEKCHH35YTz31lEaPHq1z587p2muvVWJioiZNmqQ9e/aoXr16uuOOOzwdKlwoOztbp06dKvCzJJ0+fVqJiYmSchcOrFq1qkfig2vl5ORoxIgRmjt3rmJjY/XRRx8pKyvL9trn4Roo3zp37qyrr75aPXr0UFRUlCpVqqTExEQtXbpU//nPf2S1WtWvXz9FRER4OlS4QNeuXQv9zueZO3euhg0bpuuuu07ffPONmyODO/373//W+vXrNXjwYDVt2lQ1a9bUsWPHtHTpUk2bNk3Z2dm69tprCzwwBzylfv36uuOOOzRv3jz17dtXb7zxhlq1aqXdu3fr4Ycf1oULFxQXF6eOHTt6OlS4UHp6upKTkwttzz8NaOXKlVkrqZw6d+6c+vbtq9WrV6t379564403dPr06ULluAbKr/T0dF122WUaNWqUrrrqKkVFRcnPz09HjhzRF198Yftw8siRIz0cKVxl5MiRDl/fp59+Ws8995zGjh2rV155xc2RwZ1GjBghi8Wim266SY0aNVJYWJgOHTqk+fPn67333pMkjRkzxrZ+TllnMQzD8HQQ8IysrCwNGDBA3333XaF9FStW1LJly9S5c2cPRAZ32bJli9q0aVNkmbvuukvvv/++ewKCWyUmJjqcSim/oUOHau7cuW6ICJ7QvHnzIte9aN++vb799ltGY/igvERO3759SeSUc3nTazrSunVrLVmyhGk34TVOnz6trl27aufOnYX21atXT6tXr1aDBg08EBncZf78+cV+6PD555/XE0884aaI4E7Lly9Xz549iy3HNVB+ZWRkqEKFCrJarQ7L3HLLLZo3b56Cg4PdGBm8QV4iZ8KECSRyyrnBgwfrk08+cbi/X79++vTTT8vNtJuMyPFhgYGBWrRokd599119/PHHOnDggCpXrqwuXbroscceU+PGjT0dIlwsMDCw2IcyjMQov/z9/U09lAsLC3N9MPCYtWvX6rPPPtPixYu1d+9enT59WlWrVlVsbKxuuukmDRo0SP7+/p4OEx4QGhqqWrVqsTaSD3jqqad05ZVX6pNPPtGOHTt09OhRhYaGqnnz5howYIDuvPNOBQYGejpMwKZ69epav369XnvtNS1atEjHjx9XeHi4evfurUcffZQpVHxA3v+jisKC9+VXUVPD5sc1UH4FBwfr0KFD+vjjj/Xjjz9q//79SklJUXh4uNq0aaPbb79dvXv39nSY8JDKlSurVq1aqlKliqdDgYu9//77uvXWW/X5558rPj5eJ06cUNWqVXXFFVdo8ODB6t+/v6dDdCpG5AAAAAAAAAAAAHgp+6sBAQAAAAAAAAAAwONI5AAAAAAAAAAAAHgpEjkAAAAAAAAAAABeikQOAAAAAAAAAACAlyKRAwAAAAAAAAAA4KVI5AAAAAAAAAAAAHgpEjkAAAAAAAAAAABeikQOAAAAAAAAAACAlyKRAwAAAAAAAAAA4KVI5AAAAAAAAAAAAHgpEjkAAAAAAAAAAABeikQOAAAAAAAAAACAlyKRAwBwuZSUFHXr1k2jR4/2dCgu4wvHCAAAAPiSnTt3qlu3bpoyZYqnQ3EZXzhGACgPAjwdAACg/MvOztbKlSuVnJzs6VBcxheOEQAAAPAl586d08qVKxUZGenpUFzGF44RAMoDEjkAAJerVKmSVqxYoUqVKhXYfvr0ad1yyy1q1qyZ3n77bQ9FZ05xsTo6RgAAAABl0+WXX64VK1aoZs2aBbZv2bJF48aNU8+ePTVx4kQPRWdOcbE6OkYAgHchkQMAcLmAgAB169at0PbMzEytXLlSKSkp7g+qhIqL1dExAgAAACibqlSpYvc9fnJyslauXKl69eq5P6gSKi5WR8cIAPAurJEDAAAAAAAAAADgpSyGYRieDgIAUL6lpKToxhtvVJMmTfTee+9Jkj788EPNnDlTa9asUaVKldSuXTtb+S5duujZZ5+1/ZyRkaFPPvlEP/74o44dO6bQ0FC1adNGI0eOVFRUVIG+jhw5oqFDh6p169aaPn26Fi1apM8//1yHDx/WjTfeqIceekiSlJOTo2XLlun777/Xvn37lJmZqYYNG2rAgAHq3bt3gTbNxGrvGPM7efKkZs+erfXr1+vcuXOqWbOmevTooSFDhig4OLjIY/juu+/0ySef6NixY6pVq5YGDRqkG2+8sZSvBgAAAAAzdu7cqfvuu0/XXnutJk2aJEl67rnntGDBAm3dulU1a9bUZZddZis/ZMgQ3X333bafz549qw8//FC//vqrTp06papVq6pTp04aOXKkqlevXqCvNWvW6Mknn1T//v314IMP6qOPPtKyZcuUmJioBx54QDfffLMkKT09XV9++aV++eUX7d+/XxaLRTExMRo8eLA6dOhQoE0zsdo7xvz27dun999/X9u2bVNaWprq16+vPn366Oabb5bFYnF4DOPGjdO8efP07bff6vTp02rYsKFGjBihq666qpSvBgD4OAMAABc7c+aMIclo1aqVbdvUqVMNSXa/BgwYYCu3Y8cOo0mTJnbLhYaGGp9++mmBvuLj4w1JRlxcnDFixIgC5ceOHWsr17RpU4f9Dxs2zLBarSWK1d4x5lm6dKlRpUoVu/WbN29u7N+/3+Ex3H///XbrTZ48udSvBwAAAIDirVmzxpBk3H777bZtQ4cOdXhv8Pjjj9vK/fDDD0Z4eLjdchEREcavv/5aoK9FixYZkowRI0YY11xzTYHyr7/+umEYhpGUlGRUrVrVYf9Tp04t0KaZWO0dY553333XCAwMtFu/W7duRnJyst1jGDVqlHH99dcXquPn52fMmzfv0l4UAPBRjMgBALhccnKyqlWrplatWmnLli2SpIMHD2rjxo269dZbFRMTo7fffttWvkaNGmrRooXOnj2rK664QocOHdKNN96om2++WXXq1NH58+e1YsUKvffee7JYLNqxY4eaNGkiSdqzZ4+aNm2qoKAgZWdna8yYMerevbtq1aqlunXrqmnTppKkWrVqqUePHurSpYsaNmyojIwMbd68WTNnztSpU6c0Z84cDR8+3HSs9o5Rkg4dOqTY2FidP39erVu31tixY1WnTh3t2LFDL7/8spKSktSmTRv99ttv8vPzK3QMhmHo7rvvVlxcnAICAvTFF1/oww8/VEBAgA4ePKg6deq48qUDAAAAfNbatWt11VVX6fbbb9f8+fMlSX/88YeWLl2q8ePH67rrrtPEiRNt5aOiotS4cWPFx8erbdu2SktL09ChQ9WrVy9FREQoKSlJX375pT799FPVqlVL8fHxqly5siTpm2++Ub9+/RQUFCR/f3899NBD6tixo6pVq6amTZuqbt26SkxMVExMjIYMGaJ27dqpfv36On/+vH755Re98847SktL09q1a3XllVeajtXeMUrSr7/+qq5du8pqtapnz54aMmSIqlWrpnXr1mnatGlKT08vVCf/MQQHB+uhhx5S+/btlZaWpv/973/6/vvvVbNmTR0+fFiBgYEufe0AoNzxcCIJAOADHI1WOXbsmCHJaNeund16zz//vCHJePrpp+3uf+uttwxJxmOPPWbbljeaRZLx3nvvOYwpKSnJ7vZNmzYZkozevXuXKFZHx/j4448bkoxOnToZmZmZBfYdOHDAqFSpkiHJ+Pbbb+0ew8KFCwv1lffptg8//NDh8QEAAAC4NI5Gq6xYscKQZAwdOtRuvbvuusuQZMydO9fu/nHjxhmSjP/973+2bXmjWSwWi7Fq1Sq79TIzM41z587Z3Tdv3jxDkvHQQw+VKFZHxzhgwABDknHnnXcWqvPzzz8bFovFsFgsxp49ewodQ1BQkLF58+YCdbKysmyzImzYsMFuLAAAxwLcljECAKCElixZIklatmyZ1qxZI+OvQaR5/6ampkqStm/fXqhujRo1NGrUKIdth4WF6euvv9b333+v/fv3KzU11dauxWLRrl27nHIMK1eulCRNnDix0KfOGjRooNGjR2v69OlauXKl+vTpU2B/VFSUbrnllkJtdu/eXUuWLNHRo0edEiMAAAAA58m7j5k1a5Zmz55d6D7m5MmTkuzfx1x11VXq2rWr3XYDAwNltVr14YcfatWqVTpy5IjS0tJkGIbS09Mlyen3Mc8880yhfV26dFHv3r21ZMkSrVq1StHR0QX2d+vWTa1bty6wLSAgQF26dFF8fDz3MQBQCiRyAABe69ChQ5Kk9evXF1kuL6GTX5MmTQotvpnn/Pnz6tOnj1avXu2wzZSUlBJE6tjx48clqcDCovnFxsZKkhITEwvta9iwod06VapUkSRlZmY6IUIAAAAAzpKVlWW7B1ixYkWRZe3dx8TExDgsn5CQoOuuu07x8fEOyzjjPiYzM1PJyckKCgpS48aN7ZaJjY3VkiVLuI8BADchkQMA8FrBwcGScj/J5ugGQsodXeOorj0vv/yyVq9eraioKI0dO1YxMTGqWrWq/P39ZbVa1atXL9un5S5VSEiIJOnMmTN2958+fbpAufwcJaIAAAAAeKeAgAD5+/vLMAwtXry4yPsSe+tdFlX+kUceUXx8vFq1aqWRI0eqUaNGqlixovz9/XXo0CENHz7cKfcxgYGB8vf3V2ZmplJTU23r+OTHfQwAuBeJHACAx/j7+0uSsrOz7e5v2bKl/vjjDx07dqzIadJKKm8kzhdffKG2bdsW2Ldhwwbl5OSUOFZHmjdvrj/++EPffvut2rdvX2j/4sWLJTkesQMAAADAuxR1b2CxWNSiRQtt3bpVKSkp6t27t9P6Xb16tfz9/bVq1Srb6JY8H374YYljdcRisSgmJkZ//PGHFi9erNtvv73A/vT0dP3444+SuI8BAHfx83QAAADfVaVKFfn5+enAgQN2pwAYM2aMJOlf//qX/vvf/9rmfc6TmJio1157TevWrStRv3mfGsub9znPpk2bNGzYsFLF6sjgwYMlSS+99JIWLlxo256VlaUnn3xSK1euVGBgoG699dYSHQMAAAAAz6hWrZokaefOnbJarYX2593H/OMf/9CCBQsKldm7d6+eeeYZHT58uET9hoSEKCcnR7/88kuB7cuWLdP48eNLFasjefcx48ePL9DfuXPnNHLkSB08eFC1atVSt27dSnQMAIDSIZEDAPCY4OBgtW/fXmfPnlXDhg119dVXq1u3bnr66aclST169NCECROUnZ2tf/7zn4qIiNAVV1yhjh07qnbt2qpTp44mTJigY8eOlajf2267TZL08MMPq379+urcubMaN26sdu3aqXLlygoMDCxxrEX11adPH124cEEDBw5UnTp11KFDB0VEROiFF16QJE2dOlX169cv0TEAAAAA8IxmzZopPDxcv//+u6KiotS1a1d169ZN77zzjiTp3nvv1YABA3T27FkNGjRI4eHhatOmjdq2basaNWqoSZMmmjJlSonXs8m7j+nTp49iYmJ01VVXqW7duurdu7eaNWtWqlgdmTBhgmJjY3Xs2DF17dpVjRo1Utu2bRUZGan58+fLz89PM2bMsDu1GgDA+UjkAAA86s0331TDhg2VlJSkX3/9VStXrtT27dtt+1955RV99NFHio2NVUpKirZv364NGzYoMTFRtWvX1iOPPKJOnTqVqM+RI0fqhRdeUOXKlXX48GGtWbNGCQkJuvnmm/Xtt9/Kz8/+/x6Li9Uei8WiL774Qo899pgqV66sY8eO6bffftPZs2dVv359/e9//9Pjjz9eovgBAAAAeE5gYKBmzZql8PBwHTlyRKtXr9bKlSu1b98+SbnTmX3++ed67bXXFBUVpeTkZG3ZskWbN29WUlKSmjVrpilTppT4w1xTp07Vgw8+qMDAQMXHx2vt2rVKSkrS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7qfcPv74Yz388MPauXOnnnvuOU2fPt1WPu+Nc3p6usM2nfFJveIsX75cOTk5qlOnjt5++227I7ISEhLs1s1bD2n37t3KysoyPYw+7yYgIiKi2GsbAAAAKCt2795tal/e/c2liIqK0rp16xQXF6cPP/ywRHXr1aundevWFRlvSkpKobVRy5NLOX95IiIidPfdd9tmdjh48KDeeOMNvfbaa/ryyy+1YMECDR48WFLuvVPeB/5KIm9tmv79++vhhx+2W8bR/VpR3H38AJDH/jxAAOBEISEh6tKliyTp008/dVtdb2jf033nzd187NgxrV+/3m6Zffv2adu2bcW2dc8990iSPvjgA+3evVs//vijpNJNq1aUatWq6b777tOYMWMkSevWrSuwP29Byv3799utf+HCBYfH6kz5p1VwNK1e3sKcF7v22mslSWlpaSV67Xv27CkpNzG3adOmkoQLAAAAeK0vv/zS4bRrn3/+uaTcheIdrflSEnnvqb/77judP3++RHU7d+4sSfrhhx8cTp32xRdfyGq1ljq+vA955eTklLoNV7qU8+dIgwYN9Oqrr6p9+/aSCt4D5t07rVmzpsgE2sXy7tccTcmWk5OjL774osSxuvv4ASAPiRwAbpH3CZi5c+cW+6mVY8eOOa2uq2O7VK7u++qrr7bd7Dz00ENKS0srsD87O1v333+/w5um/Pr06aOoqCidPHlSgwcPlmEY6tatm2JiYkoclyQlJiYW2e/Bgwcl5U4jll/btm0lSfPmzVNKSkqBfYZh6JFHHrGtDeRKdevWlST98ccfdkc0ffrppw6TNFdccYW6d+8uSRo/frx27txpt1xaWpouXLhQoN51110nKXckVGJiosP4rFarTpw4Ye5gAAAAAA/asWOHpk2bVmj7li1b9Oabb0qSba3OSzV06FDVqlVLSUlJGjVqVJEj/dPT0wvcWwwZMkRBQUFKSUnRuHHjCpVPTEzUxIkTLym+8PBwSaUbLeIOl3L+ihqplJaWZrt/yX8POGTIENWoUUNWq1VDhw51OPtC/jVopb/v15YvX17gnirP448/rr179zqMxxF3Hz8A5GFqNQBu0bdvX40dO1YzZ87U8OHD9cEHH+jmm29W48aNZbVadfToUR08eFDLli3T5s2bC8wpfCl1XR2bJ8+LGRaLRdOnT1e/fv20du1adezYUQ888IAaNWqkI0eO6K233tL69esVHBysjIyMItdT8fPz0+jRozVp0iRt3rxZ0t+jdErjiSee0LJly3TjjTfqiiuuUN26dVWxYkUdO3ZMCxYssC1KOWzYsAL1Ro4cqRkzZigxMVEdOnTQuHHjbMczZ84crVy50nY8rtSnTx9VqVJF586dU1xcnB5++GG1aNFC58+f19dff6158+YpKCjIYRzvvvuuOnTooJMnT6pDhw4aOXKkunXrpsqVKyshIUGbNm3S/Pnz9f3339s+mSVJs2bNUseOHfX777/rsssu01133aXOnTurevXqOn36tI4dO6Zt27Zp0aJFGj58uF555RWXngcAAADgUgUHB2vChAnatGmTbrnlFoWGhmrNmjWaPn260tLSVL9+fT355JNO6atChQr66KOPdMMNN+izzz7Txo0bNXz4cLVp00YVKlTQiRMndPToUa1Zs0ZLly7VnDlzNHDgQEm5ozuefvppTZo0SbNnz9bBgwc1atQoRUREaPv27Xr11Vd19OhR2xTapdGpUydt3LhRM2bMUEREhJo1a2YbpdOtWzennINLcSnnr0ePHrJYLOrTp4+aNm2qunXrymKxaP/+/Zo1a5YSEhIUFBSk22+/3dZfxYoVNXv2bA0YMEC//fabLr/8ct1zzz22e6QDBw5o1apV+vbbb5WammqrN3jwYP33v//V3r171blzZz3wwAOKiorS0aNH9cEHH2jFihWlum909/EDgI0BABfp0aOHIcno0aNHico//vjjRZazWq3Gs88+a4SGhhqSHH5FRUU5ta4ZpW3/u+++MyQZ/v7+Dts+efKkrf7vv//u1r7zvPnmm0ZgYKDddocPH27ccccdhiTjgQceKLKdo0ePGgEBAYYko0aNGkZGRkaxfTvyxBNPGBaLxeHxBgQEGJMnT7Zb9/nnn7dbx2KxGOPHjzcGDRpkSDLuueeeQnV79+5tSDImTJhQZHzff/+9rV1HvvzySyMkJMRuLA0bNjTmzp1r+/nMmTOF6u/YscOIjY11eA5CQ0ONnTt3Fqq3f/9+o0uXLkVeL35+fsbLL79c5DECAAAAcXFxhiSjd+/ehfZVrVrVkGQsWLDAbt3i7nUMwzB+/vlnW5m0tDS77U+bNs1o3ry53fe1DRo0sPue2DAMo1mzZoYkY+bMmSXaZxiGsWrVKqNRo0ZFvqcODQ01li9fXqCe1Wo17r//frvl/f39jVdeecXW97vvvmu376LEx8cbYWFhdts3e3yuft0Mo3Tn79prry2yfFhYmLFw4UK78SxatMioWbOmw7qRkZGF6vzrX/9yWL5r1662+8pmzZoVquuK6+dSjh8AGJEDoJBWrVopOztbrVq1KlH5xo0bF1nOYrFo4sSJGjNmjD777DOtXbtWJ06cUFBQkGrXrq2GDRuqV69eateunVPrmlHa9qtXr664uDj5+/s7bDswMFBxcXGScj9N5M6+89x3333q0aOHZs+ebVsksnHjxrr11lsVFxdnm+e3Ro0aRbZTu3ZttWzZUps2bdKIESMUFBRUbN+OPP/883rooYe0ePFibdmyRYcPH9b58+cVHh6u1q1ba9CgQQ6vqSeeeELdu3fX3LlztWfPHgUEBKhZs2YaPHiw2rdvrylTpiguLs7utG8tW7ZUenp6sfNrV6tWzfa6OTJgwABt375ds2bN0tatW2W1WhUZGam4uDgNHjxYhw8ftrUREFD4f7mXX365tm3bpm+++UbLli3TgQMHJEn169dX27ZtNWjQILvD6hs2bKiff/5ZP//8sxYvXqxdu3YpJSVF4eHhql27tlq3bq0+ffoU+3oCAAAArVu3lpT7PvliXbp0UUpKiiIiIuzWLe5eR8qdJiqvjKO1JevVq6ctW7boo48+0ooVK3TixAlVqVJFcXFxGjlypMO2O3bsqMjISNWpU6dE+ySpa9eu2r17t7755hv98MMPOnjwoDIzM1WrVi3VqVNHnTt31nXXXafQ0NAC9SwWi/773//qjjvu0Lx587R3716FhITo8ssv19ChQxUbG6utW7cqMjJStWvXttt3UZo0aaLt27fr/fff16ZNm5ScnGx3vZyijs8dr1tpzt8PP/ygXbt22e5hjhw5oszMTNWuXVudOnXSoEGDbGuiXuzGG2/Uvn379Omnn2rVqlVKTExUaGiooqKidM0116hfv36F6jzzzDPq1auX7b4xMDBQDRo00A033KC+fftq0aJFiouLU1RUVKG6rrh+LuX4AcBiGCYWRgAAlFvnzp1T3bp1lZKSoi+++EI33XSTw7L79u1T06ZNZRiGdu3apaZNm7ovUAAAAADlSlhYmM6ePasFCxbYpp8CAACF2f8oBACgXHG0IKTVatX48eOVkpKiypUr67rrriuyneeee05Wq1XXXXcd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MBxO65WTk6Pvv/9eklS3bt1S97FkyRKtW7fO9vPPP//ssOwDDzzgM4mcDz74wNMhAAAAAIANiRwAAAD4FMMoes0TbzF16lRt27ZNt99+u6KjoxUZGakzZ85o69ateuONN/Tbb7+pUqVKuvXWW0vdx4wZM3Tu3DlTZcvrFGJnzpxRtWrFr0tWnPT0dBmGodDQUCdEBQAAAAB/I5EDAAAAeKGcnBwtXLhQCxcutLu/cuXK+vjjj1W7du1S99G2bdtS1y0vevTooY4dO+qpp55yOK1cUdLT0/Xee+/p+eef19KlS9WiRQsXRAkAAADAl5HIAQAAgM8wJBluWCPHGV383//9n6666ip9+umn2r59u44cOaKgoCA1bNhQ3bt315gxY0qcxBk5cqTq1Kmjm2++We3bt3dClGVf//799fzzz+u9995Tjx49dOedd6pXr16qVauWwzoXLlzQ2rVrNX/+fC1YsEDJycnq3r276tSp48bIAQAAAPgKEjkAAACAFwoICND111+v66+/3mltHj58WO+//77+/e9/q169ehowYIBuuukmdevWTQEBvnlr8K9//UvDhg3TxIkT9dlnn2nZsmWSpAYNGuiKK65QeHi4wsLClJqaqtOnT2vfvn3avn27srOzJUlt2rTRlClTdOONN3ryMAAAAACUY755twYAAAAfZXHTGjneuQ7P0qVLtWbNGn355Zf68ssv9eabb+rNN99UtWrV1LdvX9100026/vrrVbFiRU+H6lbR0dGaP3++jhw5olmzZunzzz/X77//roMHD9otHxUVpV69emn06NG68sor3RwtAAAAAF9jMQx3TC6B4sTGxkqSduzY4eFI4E2siTHFlvGL3O32ttzNTOySdNk7Y4sts2vSeFNttVw0qdgyGZvNLYwc3OZMsWW29Ztiqi0zzMQe+mmYqbbSBiUXW8aZ58EsM32GxZv739ual98qtkzrDYNNtbWlw3ynteWs/swy8/tv5toyy5mxe4K3/r30Rt70Hic2Nlbx506p/isPuryvQ4+8oaZVanjFcRdl+/bttqTOxo0bJUkhISHq2bOnbrrpJvXr108REREejtIzzp49q02bNun48eM6ffq0KlWqpBo1aujyyy9Xw4YNPR2e1/Cm33EAAADAU1z9vpgROQAAAPAdhiR3jMgpIx+VatGihVq0aKGnn35ahw4dsiV1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nitl7JNu+0SKrOG3eOXxeDxyu93asGGDGjU60VMoJydHI0eO1A033KBt27apU6dOlmYEAABAYAv3x0F++eUX5efn++NQAAAAAEwaNGjQaRVxQsXYsWMVHR2tNWvW6IEHHlBmZqYkye12a8GCBXr88cclSQ888MBZHSc1NVVDhgxRXFycrrjiCj355JP697//re+///6U23m9Xt1///0677zzNH78eD311FO655571Lp1a02bNu2sMgW8bx87dRHnzw79JH33eOXmkeRwOLR+/XpJJ4prq1at0qpVq7Rv3z5JUmRkpJ588smSIo4kxcTE6MEHH5TH49EPP/xQ6RkBAAAQ3PzSI+e5557T999/r1GjRmns2LG6+OKL/XFYAAAAAKewadMmZWVlnbJNRESEYmNj1b59e9WqVctPyazVsmVLzZo1S2PGjNHrr7+uGTNmqEGDBsrOzlZhYaEk6brrrjvrQs6xY8f09ddfKzIyUn379pXT6dSGDRsq3G7SpEl64403FBERoRtvvFFt27bVli1b9M033+jxxx9Xo0aNNGbMmLPKFpByjp0YTu10bJ0nXfFEpc6Zc/DgQU2fPl2S9NVXX2nLli2SpBEjRuj+++8/6XaHDx+WJLVt27bSsgEAAKBq8Eshp2nTpsrKytKbb76pN998UxdccIHGjh2r2267jTGlAQAAAIs88MADpgoHkhQeHq6BAwdq2rRp6tKlSyUns97tt9+u1q1ba+rUqVq9erXS09MlSR07dtRf//pXjR8/XuHhZzfAQdOmTbVkyRL169dPtWvX1qRJkyp8PVJSUvTSSy9JkhYvXqxhw4aVrHv++ef12GOP6fHHH9ett96qqKios8pX6QxDKjx1IbGUjbPKzolTEa/rxHa9Tl5QKSO6rhQWZrp5165dtWDBArVu3VoPPfRQmTlyitntdm3fvl2FhYXatm2bXnzxRd1///26+uqrzWcDAABASPJLIefVV1/VX//6V7377rv64IMPtH37dk2YMEH/+Mc/dMMNN+juu+/WFVdcobDT+GMZAAAAOBNhzJFT4pprrlHr1q31ySefyO12q2vXrmrbtq1sNpt++eUXZWdnq1u3bqpfv742b96s7777TmvWrNGPP/6obt26WR2/0vXp00dLly6Vx+NRVlaWatWqperVq/ts/40aNdKQIUNOa5vFixerqKhI/fv3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bKj09HQdPnxYrVq1siKmJW6//Xa1a9dO//rXv/TDDz+ooKBATqdTHTp00Pjx43Xffb4bWhRV1/r160v+PyoqSs2bNz/pvEper1e///67wsLC1L59e4WHh2vDhg1q0qSJWrZsWe422dnZ2rp1qyTpwIEDJcdr0qSJWrVqVe4cOYWFhdq2bZvatGlT8vt97NgxtWvXrtSIGMePH1dycrLatWunWrVqnfQ52u12HTx4ULVr1z7joekAAAg0Ho+nzPBobrfb6lgIYX4r5ERERGjx4sV68cUXNW3aNNlsNi1btqxMu27dumn69Om67LLL/BUNAAAACEl7956YV+nCC08+GVjTpk2Vnp6ujIyMkCrkSCd64Hz11Vfyer3KyspSjRo1GPoZpyUxMbHk/7Ozs7Vv3z716tVLH374oVq0aFGybu3atbr99tt17NgxtWrVSpGRkVq0aJF69+6txMREvfjii+Xuf9++fZoy5cR8kosWLdLq1aslSSNHjlRiYmK5c+QcPHhQvXr10rvvvqvNmzfru+++U15ensLCwvTZZ5+pe/fuuv/++7V06VIVFBQoLy9PH330kYYMGVLq2MnJybr33nu1bNkynXPOOcrIyFBMTIzefvtt9e/f35enEQCASuX1euV0OksVbv53yGHAan4r5EhSeHi4Hn30Ud1///1as2aNtm7dqoyMDEVFRalp06a67LLL1LVrV39GAgAAAEJW8Z3z4eHhpX7+s4yMDEk65R35VV14eLjq1atndYyA4XA45HA4JEkul0sRERHWBgpgf+6RI52Yz2bIkCG688479cMPP0iSUlJSdM0116hHjx7asWOHateuraNHj+qRRx6pcP/dunXTZ599ptatW+vRRx/VvffeazrbzJkzNXHiRL3xxhtyu90aOnSobrnlFt1222268sorNXPmTHk8Hg0bNkzjxo3TgQMHVL16dUlSTk6O+vXrp5o1a2rPnj1q3bq1vF6v/vGPf2jo0KHasmWLzjvvvNM4UwAA+IdhGGWKNkVFRVbHAirk10JOsZo1a2rQoEEaNGiQFYcHAABACAtjaLUSrVu31sqVK5WUlKT+/fuXKeQcPXpUKSkpioqKUkJCgjUhLWS327VlyxYdPHhQBQUF5ba55ZZbFBcX5+dk1po+fbomT55c8nPDhg0tTBP43G639u/fL4fDIa/Xq/79++uFF15Q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MHv27NGOHTvk9Xp15ZVXmvoOvnfvXv3++++KiopS165d1bhxYz8krQQGUEVs3brVuPHGG42aNWsakko9OnXqZKxYscLqiEHlq6++MgYNGmRERUWVOZ+XXnqpsXXrVqsjBp2PPvrIuPbaa42YmJiSc1mrVi2rYwWsnTt3Gt26dSv13qtWrZpx//33G06n0+p4QSUpKcm4//77jfbt25c6n2+//bbV0YLSu+++a/Tu3dsIDw8vdT7DwsKMoUOHGocPH7Y6IgAgRC1atMjo37+/Ua1atTJ/w19xxRXG7t27rY6IIJKRkWE0bNiw5D3E+wcVsdlsxj333GNUr1691OdPq1atjPfff9/qeAhQXq/XePnll434+Pgy/3ZdfPHFXM+DsXnzZmPs2LFGixYtSr0/Vq5cecrtkpKSjD59+pTaJjw83LjzzjuN3Nxc/4T3oTDDMIzKLBQB/vLiiy/qH//4h2rXrq1zzz1XLVq0kN1u18aNG1VQUKBq1app1apV6t27t9VRg8LIkSP18ccfKyYmRm3btlWLFi2UmZmpDRs2yO12KyYmRuvXr9f5559vddSg0bNnT23YsEHVqlVTly5dtGXLFtWqVUu5ublWRws4R48e1UUXXaT09HTFxcWpd+/estvt+vnnn+X1enXHHXfovffeszpm0FiwYIFGjRolSWrZsqVcLpdSU1P19ttv6+6777Y4XfDp2rWrtm/frvr166tNmzZq0qSJjh49qs2bN0uSWrRooa1bt9J7DADgd4MGDdLSpUtVt25dtW3bVs2aNVN6ero2btwor9eruLg4bd68WQkJCVZHRRC444479Nlnn8nr9So/P1+7d+/WeeedZ3UsBKiMjAz169dPu3btknTib+bWrVvr2LFj2rRpk6688kp99913FqdEIJo6daqeeuopSVJCQoI6deqkwsJCbdq0SVlZWapWrZp++ukn9ejRw+KksMq//vUvPfnkk5Kkc889V2lpacrKytLKlSvVr1+/crex2Wzq1q2bDh06pJiYGPXt21d5eXn68ccf5fF4dPXVV+vrr7/247M4e8yRgyrjoosu0rfffiubzaZffvlFn332mVatWqXk5GT17dtXbrdbL7/8stUxg8aQIUP07bffKjMzU1u2bNEXX3yhn376Sb/99psSEhKUk5OjqVOnWh0zqNxyyy36/PPPZbPZ9MUXX1gdJ6BNmjRJ6enp6tmzp/bv368vv/xSP/74o5YuXarIyEi9//77Wrt2rdUxg0abNm30yiuvaPfu3Tp06JAuvfRSqyMFtbFjx+qnn37S8ePHtWHDBn3++ef65ZdftHbtWtWpU0dHjhzR66+/bnVMAEAIuvHGG/X9998rMzNTv/zyi7744gutW7dO27dvV3x8vGw2m6ZNm2Z1TASB5cuX64MPPtC//vUvhlODKePGjdOuXbvUqFEj/fzzz9q6das+/fRTrV27Vvv379cdd9xhdUQEqNdee02SNHHiRCUlJWnJkiVavny5jh49WnI9b+bMmRanhJUuuugivf322zp8+LD27Nlj6oaU//znPzp06JA6duyoffv26auvvtLKlSu1bt061apVS998842+/PLLyg/vQxRyUGVcccUVGjRokCIjI0str1evnhITEyVJKSkpFiQLTrfddlu557Ndu3b6z3/+I0natm2bBcmC14QJEzRs2DC+CFUgPz9fixYtkiS99dZbqlu3bsm6v/zlL7rnnnskSXPnzrUiXlDq3r27JkyYwB2UPvLAAw+od+/eCgsLK7X80ksvLfn3hs9HAIAVxo4dq/79+ysiIqLU8k6dOpXc7cy/UahIfn6+/vrXv6pHjx564IEHrI6DILB9+3Z99tlnkqR58+apV69epdY3b95ct9xyixXREOC8Xq/sdrukE9dMwsP/71J17dq1NW7cOEnS8ePHLcmHwDB48GDdfffdatGihan2Xq9XH3zwgSTp1VdfLTWPTvfu3fXwww9LkubMmeP7sJWIQg5CwsaNGyVJ3bp1szhJ1VA8KVhsbKy1QVAl/fLLL8rPz1ebNm3UpUuXMuuHDx8uSVqzZo2/owEV4vMRABCo+DcKZj355JM6evSo3n777VIXVYGTWbhwoSTp4osv1l/+8heL0yCYhIeHl1yr27RpU5n1GzZskHTi4jtg1p49e5Senq569eqVO/Ra8XWlH3/80c/Jzk41qwMAvnbs2DGtXLlShmEoIyNDq1at0meffabzzjuvZDxFnJ158+ZJ+r8PPsCX9u7dK+nEnaPlKV6elJQkj8dT5o5TwCqGYeijjz6SxOcjACDw8Dc8zNi8ebNeeeUVPf744+rcubPVcRAkii/ADx48WJJ0+PBhbdu2TVFRUerSpYuaNm1qZTwEuNdee02DBw/WqFGjdNddd+mC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", + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import tempfile\n", + "from pathlib import Path\n", + "from autosampler.analysis import plots\n", + "\n", + "run = Path(tempfile.mkdtemp())\n", + "rng = np.random.default_rng(0)\n", + "for it in range(6):\n", + " d = run / f\"iter_{it}\"; d.mkdir()\n", + " np.savez_compressed(d/\"msm.npz\",\n", + " lagtime=np.asarray(10),\n", + " timescales=np.array([100.0+it*3, 50.0+it]),\n", + " stationary_distribution=np.array([0.5,0.3,0.2]),\n", + " transition_matrix=np.array([[.9,.08,.02],[.05,.9,.05],[.02,.08,.9]]),\n", + " cluster_centers=np.zeros((3,2)),\n", + " vamp2_score=np.array([1.8+0.12*it]),\n", + " metastable_populations=np.array([0.6,0.4]),\n", + " its_lagtimes=np.array([1.,2.,5.,10.]),\n", + " its_timescales=np.array([[90,40],[95,45],[99,49],[100,50]], float))\n", + " np.savez_compressed(d/\"cvs.npz\", cvs=rng.normal(size=(200,2)))\n", + "\n", + "out = plots.plot_convergence_report(run)\n", + "from IPython.display import Image\n", + "Image(filename=str(out))\n" + ] + }, + { + "cell_type": "markdown", + "id": "32d741e7", + "metadata": {}, + "source": [ + "## 7. Running a real campaign\n", + "\n", + "```bash\n", + "autosampler-init -o config.yaml # write & edit the input file\n", + "autosampler --config config.yaml --check # validate\n", + "autosampler --config config.yaml --iterations 200\n", + "autosampler-analyze --run-dir runs/my_run\n", + "```\n", + "\n", + "Switch `execution.backend` to `slurm` or `pbs` to scale out on a cluster — no\n", + "other changes needed. See the documentation for the full reference." + ] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.15" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/pbs_submit.sh b/examples/pbs_submit.sh new file mode 100755 index 0000000..a7a3370 --- /dev/null +++ b/examples/pbs_submit.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +#PBS -N autosampler-driver +#PBS -l select=1:ncpus=2:mem=8gb +#PBS -l walltime=24:00:00 +#PBS -j oe +# +# Driver job for AutoSampler on a PBS / Torque (PBS Pro) cluster. +# +# AutoSampler submits one *array job per iteration* (qsub) for the walkers, so +# this driver only runs the orchestrator. Set `execution.backend: pbs` in your +# config (see docs/execution.md). +# +# Submit with: qsub examples/pbs_submit.sh +set -euo pipefail +cd "${PBS_O_WORKDIR:-$(pwd)}" + +# --- Make the autosampler package importable in walker jobs too ------------- +# Mirror these in the config's execution.module_loads. +# module load openmm +source "$(conda info --base)/etc/profile.d/conda.sh" +conda activate autosampler + +CONFIG="${CONFIG:-examples/AIB9/config_msm_vampnet.yaml}" +ITERATIONS="${ITERATIONS:-200}" + +autosampler --config "${CONFIG}" --check +autosampler --config "${CONFIG}" --iterations "${ITERATIONS}" --log-level INFO diff --git a/examples/run_local.sh b/examples/run_local.sh new file mode 100755 index 0000000..b79596d --- /dev/null +++ b/examples/run_local.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Run AutoSampler locally (multi-GPU workstation or a single machine). +# +# Usage: +# ./examples/run_local.sh [CONFIG] [ITERATIONS] +# +# Examples: +# ./examples/run_local.sh examples/AlaD/config.yaml 20 +# ./examples/run_local.sh examples/AIB9/config_msm_vampnet.yaml 200 +set -euo pipefail + +CONFIG="${1:-examples/AIB9/config_msm_vampnet.yaml}" +ITERATIONS="${2:-200}" + +# Optional: cap MD subprocess runtime (seconds) to catch hung GROMACS/Amber jobs. +export AUTOSAMPLER_MD_TIMEOUT="${AUTOSAMPLER_MD_TIMEOUT:-3600}" + +# Validate inputs first (no MD is run). +autosampler --config "${CONFIG}" --check + +# Run. With msm.enabled, this stops early once the MSM converges. +autosampler --config "${CONFIG}" --iterations "${ITERATIONS}" --log-level INFO + +# To resume after an interruption: +# autosampler --config "${CONFIG}" --resume --iterations "${ITERATIONS}" diff --git a/examples/slurm_submit.sh b/examples/slurm_submit.sh new file mode 100755 index 0000000..e9d207a --- /dev/null +++ b/examples/slurm_submit.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +#SBATCH --job-name=autosampler-driver +#SBATCH --time=24:00:00 +#SBATCH --cpus-per-task=2 +#SBATCH --mem=8G +#SBATCH --output=autosampler_driver_%j.out +# +# Driver job for AutoSampler on a SLURM cluster. +# +# AutoSampler itself submits one *array job per iteration* (sbatch) to run the +# walkers, so this driver is lightweight: it just runs the orchestrator. Set +# `execution.backend: slurm` in your config (see examples/AIB9/ +# config_msm_feature_selection.yaml and docs/execution.md). +# +# Submit with: sbatch examples/slurm_submit.sh +set -euo pipefail + +# --- Make the autosampler package importable in walker jobs too ------------- +# These same module loads / env activation should be mirrored in the config's +# execution.module_loads so the per-walker array jobs can import autosampler. +module purge +# module load cuda/12.2 +source "$(conda info --base)/etc/profile.d/conda.sh" +conda activate autosampler + +CONFIG="${CONFIG:-examples/AIB9/config_msm_vampnet.yaml}" +ITERATIONS="${ITERATIONS:-200}" + +autosampler --config "${CONFIG}" --check +autosampler --config "${CONFIG}" --iterations "${ITERATIONS}" --log-level INFO diff --git a/examples/template.yaml b/examples/template.yaml new file mode 100644 index 0000000..baed0ca --- /dev/null +++ b/examples/template.yaml @@ -0,0 +1,146 @@ +# ============================================================================ +# AutoSampler input file +# +# A single YAML file fully describes a run: the system, MD engine, how walkers +# are spawned, the collective-variable (CV) space, optional feature selection +# and MSM-convergence, and where jobs execute. Paths are resolved relative to +# this file. Validate before running: autosampler --config config.yaml --check +# Full reference: docs/input_file.md and docs/configuration.md +# ============================================================================ + +# ---- System: structure, topology, and how features are read ---------------- +system: + conf_file: start.gro # coordinates (.gro/.pdb/.crd/...) + top_file: topol.top # topology + topology: gromacs # gromacs | amber | charmm + # system_file: system.py # optional: custom OpenMM System builder + # project_file: project.py # required for space_mode: fixed (defines extract_cvs) + trajectory_topology_file: start.gro + feature_selection: "protein and not (type H)" # MDAnalysis atom selection + +# ---- Engine: the MD backend and thermodynamic settings --------------------- +engine: + md_engine: openmm # openmm | gromacs | amber + platform_name: CUDA # CUDA | CPU | OpenCL | Reference (OpenMM) + precision: mixed # mixed | single | double + temperature: 300.0 # Kelvin + pressure: 1.0 # bar + dt: 0.002 # ps + npt: false # constant-pressure ensemble + equilibrate: false + # gpu_ids: [0, 1] # explicit GPUs for the local backend + # --- GROMACS-only --- + # gromacs_executable: gmx + # gromacs_include_dir: /path/to/gromacs/top + # --- Amber-only --- + # amber_executable: pmemd.cuda + # amber_input_file: prod.in + +# ---- Spawning: how the next walkers are chosen ----------------------------- +spawning: + spawn_scheme: density # density | voronoi | lof | fps | msm | we + spawn_type: hard # hard | probabilistic + search_mode: explore # explore | target + walker: 16 # walkers per iteration + step: 5000 # MD steps per walker + stride: 50 # save a frame every N steps + max_workers: 4 # concurrent walkers (local backend) + # target: [1.5, -1.2] # CV target when search_mode: target + voronoi_clusters: 150 # cells / microstates (voronoi & msm spawners) + we_target_per_bin: 4 # walkers per bin for spawn_scheme: we + lof_neighbors: 20 + # Coverage-based (legacy) convergence; superseded by msm.* when msm.enabled: + resolution_check_patience: 5 + convergence_patience: 0 + +# ---- CV space: fixed physical CVs or a learned latent space ---------------- +# space_mode: fixed | pca | tica | tvae | vampnet | spib | deep-tica | deep-lda +space_mode: vampnet +adaptive_feature_type: distances # distances | fitted_coords | phi_psi +retrain_freq: 5 # retrain cadence for retrain_policy: fixed +retrain_policy: fixed # fixed | vamp_adaptive (retrain on VAMP-2 drop) +# vamp_retrain_tol: 0.1 # relative VAMP-2 drop that triggers a retrain +# retrain_min_interval: 1 +# retrain_max_interval: 20 +aggregate_memory: true +max_adaptive_memory_frames: 50000 + +adaptive_model: # hyperparameters for learned CVs + lagtime: 5 + latent_dim: 2 + epochs: 50 + learning_rate: 0.0005 + encoder_hidden_dims: [64, 32] + decoder_hidden_dims: [32, 64] + dropout_rate: 0.1 + deep_tica_hidden_dims: [64, 32] + spib_n_states: 10 # SPIB only + spib_beta: 0.001 # SPIB only + +# ---- Feature selection: VAMP-2 optimisation of the input features ---------- +feature_selection: + enabled: false # opt-in + method: greedy_vamp # greedy_vamp | all + lagtime: 10 + cadence: 5 # re-select every N iterations + # max_features: 50 + # min_gain: 1.0e-4 + # candidate_feature_types: [distances, fitted_coords] # rank types by VAMP-2 + +# ---- MSM: build a Markov State Model and stop on convergence --------------- +msm: + enabled: false # opt-in; stops sampling on convergence + cadence: 1 # estimate the MSM every N iterations + min_frames: 2000 # wait for this many cumulative frames + lagtime: 10 + lagtimes: [1, 2, 5, 10, 20] # implied-timescale sweep (diagnostics) + n_microstates: 100 + cluster_method: kmeans # kmeans | regspace + estimator: mle # mle | bayesian (error bars) + n_timescales: 3 + n_metastable: 4 # PCCA+ coarse-graining + stable_clustering: false # comparable microstate IDs / T_ij across iters + # MSM-guided spawner (spawn_scheme: msm): uncertainty x leverage x flux + spawn_alpha: 1.0 # exploration / least-counts weight + spawn_leverage: 1 # slow eigenvectors used for leverage + spawn_uncertainty: true # include outflow-uncertainty factor + convergence_mode: all # all | any + convergence_patience: 3 + convergence_criteria: + - name: implied_timescales + params: {tol: 0.1, n_timescales: 2} + - name: vamp2 + params: {tol: 0.05} + # - name: transition_matrix # flux-weighted T_ij statistical convergence + # params: {tol: 0.2, min_flux: 1.0e-3} + # - name: statistical_error # needs estimator: bayesian + # params: {tol: 0.2} + +# ---- Binning: landscape-adaptive stratification for density / WE spawners --- +binning: + scheme: uniform # uniform | gradient | mab | eigenvector + # n_fine: 100 # density-histogram resolution (gradient scheme) + # smoothing: 3 # density smoothing window (gradient scheme) + +# ---- Execution: where walkers run ------------------------------------------ +execution: + backend: local # local | slurm | pbs + # --- scheduler settings (slurm/pbs) --- + # partition: gpu + # account: my_alloc + # walltime: "02:00:00" + # cpus_per_task: 8 + # gpus_per_task: 1 + # memory: "16G" + # max_retries: 2 + # module_loads: + # - "module load cuda/12.2" + +# ---- Run-level settings ---------------------------------------------------- +outdir: runs/my_run +random_seed: 42 +checkpoint_freq: 1 +save_features: true +n_bins: [30, 30] # binning for coverage / fixed-space grid +# min_values: [-3.14159, -3.14159] # fixed-space bounds (space_mode: fixed) +# max_values: [3.14159, 3.14159] diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..edfc451 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,36 @@ +site_name: AutoSampler +site_description: Autonomous adaptive MD sampling driven to MSM convergence +repo_url: https://github.com/TeamSuman/AutoSampler +theme: + name: material + palette: + - scheme: default + primary: indigo + features: + - navigation.sections + - content.code.copy + +nav: + - Home: index.md + - Quickstart: quickstart.md + - The input file: input_file.md + - Concepts: concepts.md + - Configuration reference: configuration.md + - Collective variables: cv_methods.md + - Feature selection (VAMP-2): feature_selection.md + - MSM & convergence: msm.md + - Adaptive binning: binning.md + - Analysis & plotting: analysis.md + - Execution (workstation & HPC): execution.md + - Tutorial — adaptive MSM: tutorials/adaptive_msm.md + - Tutorial — notebook: notebook_tutorial.md + +markdown_extensions: + - admonition + - pymdownx.superfences + - pymdownx.details + - pymdownx.snippets: + base_path: ["."] + check_paths: true + - toc: + permalink: true diff --git a/pyproject.toml b/pyproject.toml index 91270f0..508aa2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "numpy>=1.23", "scipy>=1.9", "shapely>=2.0", - "pydantic>=1.10", + "pydantic>=2.0", "pyyaml>=6.0", "scikit-learn>=1.2", "MDAnalysis>=2.5", @@ -45,9 +45,13 @@ examples = [ ] test = [ "pytest>=7.0", + "ruff>=0.6", +] +docs = [ + "mkdocs-material>=9.0", ] all = [ - "autosampler[deep-tica,examples,test]", + "autosampler[deep-tica,examples,test,docs]", ] [tool.setuptools.packages.find] @@ -63,7 +67,33 @@ autosampler = "autosampler.cli:main" autosampler-run = "autosampler.cli:main" autosampler-path = "autosampler.path_cli:main" autosampler-log = "autosampler.log_cli:main" +autosampler-analyze = "autosampler.analysis_cli:main" +autosampler-init = "autosampler.init_cli:main" [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] + +[tool.black] +line-length = 88 +target-version = ["py310", "py311"] + +[tool.isort] +profile = "black" +line_length = 88 + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +# Pragmatic starter rule set; expand over time as the codebase is cleaned up. +select = ["E", "F", "W", "I", "UP", "B"] +ignore = [ + "E501", # line length handled by black + "B008", # function calls in argument defaults (used intentionally) +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # re-exports +"tests/*" = ["E402"] # importorskip before imports diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_adaptive_binning.py b/tests/test_adaptive_binning.py new file mode 100644 index 0000000..d236085 --- /dev/null +++ b/tests/test_adaptive_binning.py @@ -0,0 +1,100 @@ +"""Tests for landscape-adaptive binning (gradient / mab / eigenvector).""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +warnings.filterwarnings("ignore") + +from autosampler.binning.adaptive import ( # noqa: E402 + EigenvectorBinner, + GradientBinner, + MABinner, + make_binner, +) +from autosampler.binning.spatial import RegularBinner # noqa: E402 +from autosampler.spawners import SpawnerFactory # noqa: E402 + + +def _double_well(n=4000, seed=0): + """1-D points: two dense basins at ±2 with a sparse barrier near 0.""" + rng = np.random.default_rng(seed) + x = np.concatenate([rng.normal(-2, 0.3, n // 2), rng.normal(2, 0.3, n // 2)]) + return x.reshape(-1, 1) + + +def test_gradient_bins_are_finer_at_the_barrier(): + pts = _double_well() + gb = GradientBinner(n_bins=[12], n_fine=80, smoothing=3) + edges = gb._axis_edges(pts[:, 0], float(pts.min()), float(pts.max()), 12) + widths = np.diff(edges) + centers = 0.5 * (edges[:-1] + edges[1:]) + barrier_w = widths[np.argmin(np.abs(centers))] # bin nearest x=0 + basin_w = widths[np.argmin(np.abs(centers - 2.0))] # bin nearest a basin + assert barrier_w < basin_w # sparse barrier gets finer bins + + +def test_uniform_scheme_is_regular_binner(): + b = make_binner("uniform", n_bins=[10, 10]) + assert isinstance(b, RegularBinner) + + +def test_make_binner_unknown_scheme(): + with pytest.raises(ValueError): + make_binner("banana", n_bins=[10]) + + +@pytest.mark.parametrize("scheme", ["gradient", "mab", "eigenvector"]) +def test_binner_bintable_is_consistent(scheme): + pts = np.random.default_rng(0).normal(size=(500, 2)) + binner = make_binner(scheme, n_bins=[8, 8]) + table = binner.fit(pts) + # Every frame lands in exactly one bin. + assert int(table.populations.sum()) == len(pts) + assert sum(len(d) for d in table.populated_data) == len(pts) + assert len(table.occupied_indices) >= 1 + + +def test_eigenvector_bins_only_leading_coordinate(): + pts = np.random.default_rng(1).normal(size=(600, 3)) + table = EigenvectorBinner(n_bins=[7, 99, 99]).fit(pts) + # 1-D along the leading axis -> at most n_bins[0] bins. + assert len(table.ids) == 7 + assert int(table.populations.sum()) == len(pts) + + +def test_mab_produces_front_footholds(): + pts = _double_well() + edges = MABinner(n_bins=[10]).fit(pts) # smoke: fit must succeed + assert int(edges.populations.sum()) == len(pts) + + +def test_binning_config_validation(): + from pydantic import ValidationError + + from autosampler.config import BinningConfig + + assert BinningConfig(scheme="gradient").scheme == "gradient" + with pytest.raises(ValidationError): + BinningConfig(scheme="banana") + + +def test_we_spawner_with_adaptive_binner(): + spawner = SpawnerFactory.get("we", n_bins=[6, 6], target_per_bin=3, seed=0) + spawner.binner = make_binner("gradient", n_bins=[6, 6]) + pts = np.random.default_rng(0).normal(size=(60, 2)) + idx = spawner.sample(pts, top_n=8) + assert len(idx) == 8 and all(0 <= i < 60 for i in idx) + + +def test_density_spawner_with_adaptive_binner(): + spawner = SpawnerFactory.get( + "density", n_bins=[6, 6], probabilistic=True, seed=0 + ) + spawner.binner = make_binner("eigenvector", n_bins=[6, 6]) + pts = np.random.default_rng(0).normal(size=(80, 2)) + idx = spawner.sample(pts, top_n=5) + assert len(idx) == 5 and all(0 <= i < 80 for i in idx) diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..e742179 --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,97 @@ +"""Tests for MSM analysis data utilities and plotting.""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +warnings.filterwarnings("ignore") + +from autosampler.analysis import data # noqa: E402 + + +def _make_run(tmp_path, n_iters=4): + """Build a synthetic run dir with msm.npz + cvs.npz per iteration.""" + rng = np.random.default_rng(0) + for it in range(n_iters): + d = tmp_path / f"iter_{it}" + d.mkdir() + np.savez_compressed( + d / "msm.npz", + lagtime=np.asarray(10), + timescales=np.asarray([100.0 + it, 50.0 + it], dtype=float), + stationary_distribution=np.array([0.5, 0.3, 0.2]), + transition_matrix=np.eye(3), + cluster_centers=np.zeros((3, 2)), + vamp2_score=np.asarray([2.0 + 0.1 * it]), + metastable_populations=np.array([0.6, 0.4]), + its_lagtimes=np.array([1.0, 2.0, 5.0]), + its_timescales=np.array([[90.0, 40.0], [95.0, 45.0], [100.0, 50.0]]), + ) + np.savez_compressed(d / "cvs.npz", cvs=rng.normal(size=(50, 2))) + return tmp_path + + +def test_load_msm_series(tmp_path): + series = data.load_msm_series(_make_run(tmp_path)) + assert list(series["iterations"]) == [0, 1, 2, 3] + assert series["vamp2"][0] == pytest.approx(2.0) + assert series["timescales"].shape == (4, 2) + assert series["timescales"][3, 0] == pytest.approx(103.0) + + +def test_load_latest_and_cv_points(tmp_path): + run = _make_run(tmp_path) + latest = data.load_latest_msm(run) + assert "its_lagtimes" in latest and "transition_matrix" in latest + points = data.load_cv_points(run) + assert points.shape == (200, 2) # 4 iters x 50 frames + + +def test_load_msm_series_empty(tmp_path): + series = data.load_msm_series(tmp_path) + assert series["iterations"].size == 0 + + +def test_free_energy_from_populations(): + f = data.free_energy_from_populations([0.5, 0.25, 0.25], temperature=300.0) + assert f.min() == pytest.approx(0.0) + # Less populated states have higher free energy. + assert f[1] > f[0] and f[2] > f[0] + + +def test_free_energy_surface(tmp_path): + pts = np.random.default_rng(0).normal(size=(2000, 2)) + f, xe, ye = data.free_energy_surface(pts, bins=20) + assert f.shape == (20, 20) + assert np.nanmin(f) == pytest.approx(0.0) + with pytest.raises(ValueError): + data.free_energy_surface(np.zeros((10, 1))) + + +def test_plots_smoke(tmp_path): + pytest.importorskip("matplotlib") + from autosampler.analysis import plots + + run = _make_run(tmp_path) + out = plots.plot_convergence_report(run) + assert out.exists() + # Individual plotters return an Axes without raising. + series = data.load_msm_series(run) + latest = data.load_latest_msm(run) + assert plots.plot_vamp2_convergence(series) is not None + assert plots.plot_implied_timescales( + latest["its_lagtimes"], latest["its_timescales"] + ) is not None + assert plots.plot_msm_network( + latest["transition_matrix"], latest["stationary_distribution"] + ) is not None + + +def test_analysis_cli_errors_without_msm(tmp_path): + from autosampler.analysis_cli import main + + with pytest.raises(SystemExit): + main(["--run-dir", str(tmp_path)]) diff --git a/tests/test_config_and_spawner.py b/tests/test_config_and_spawner.py new file mode 100644 index 0000000..8257d47 --- /dev/null +++ b/tests/test_config_and_spawner.py @@ -0,0 +1,81 @@ +"""Tests for MSMConfig defaults and the MSM-guided spawner.""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +warnings.filterwarnings("ignore") + +import autosampler.spawners.msm # noqa: E402,F401 (ensures registration) +from autosampler.config import AutoSamplerConfig, MSMConfig # noqa: E402 +from autosampler.spawners import SpawnerFactory # noqa: E402 + + +def _base_config(**overrides): + cfg = { + "system": {"conf_file": "a.gro", "top_file": "a.top"}, + "engine": {"md_engine": "openmm"}, + "spawning": {"spawn_scheme": "density"}, + } + cfg.update(overrides) + return cfg + + +def test_msm_disabled_by_default(): + cfg = AutoSamplerConfig(**_base_config()) + assert cfg.msm.enabled is False + # Default convergence criteria present so enabling needs no extra config. + names = {c["name"] for c in cfg.msm.convergence_criteria} + assert "implied_timescales" in names and "vamp2" in names + + +def test_msm_config_validation(): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + MSMConfig(cluster_method="banana") + with pytest.raises(ValidationError): + MSMConfig(estimator="frequentist") + with pytest.raises(ValidationError): + MSMConfig(lagtime=0) + good = MSMConfig(enabled=True, estimator="bayesian", n_metastable=4) + assert good.estimator == "bayesian" + + +def test_msm_spawner_registered(): + spawner = SpawnerFactory.get("msm", n_clusters=20, seed=1) + assert spawner is not None + + +def test_msm_spawner_prefers_undersampled_regions(): + """Frames in sparse microstates should be selected far more often. + + Averaged over several seeds with a realistic walker count, the sparse + region (1% of all frames) should attract a large share of restarts. + """ + rng = np.random.default_rng(0) + # 990 frames densely packed near origin, 10 frames in a far, sparse region. + dense = rng.normal(scale=0.1, size=(990, 2)) + sparse = rng.normal(loc=[10.0, 10.0], scale=0.1, size=(10, 2)) + points = np.vstack([dense, sparse]) + + fractions = [] + for seed in range(8): + spawner = SpawnerFactory.get("msm", n_clusters=20, seed=seed) + picks = np.asarray(spawner.sample(points, top_n=10, history=None)) + fractions.append(np.mean(picks >= 990)) + mean_frac = float(np.mean(fractions)) + # Population share of the sparse region is 0.01; least-counts must massively + # oversample it. + assert mean_frac > 0.15 + + +def test_msm_spawner_indices_in_range(): + points = np.random.default_rng(1).normal(size=(100, 2)) + spawner = SpawnerFactory.get("msm", n_clusters=10, seed=3) + picks = spawner.sample(points, top_n=10, history=None) + assert all(0 <= i < 100 for i in picks) + assert len(picks) == 10 diff --git a/tests/test_core_msm_integration.py b/tests/test_core_msm_integration.py new file mode 100644 index 0000000..98602ce --- /dev/null +++ b/tests/test_core_msm_integration.py @@ -0,0 +1,132 @@ +"""Integration test for the MSM wiring inside AutoSamplerCore. + +Exercises the real ``_collect_msm_trajectories`` and ``_maybe_build_msm`` +methods against a synthetic run ``history``, without launching MD. The core +module pulls in heavy MD/ML dependencies at import time, so the whole module is +skipped when they are unavailable. +""" + +from __future__ import annotations + +import types + +import numpy as np +import pytest + +pytest.importorskip("deeptime") +pytest.importorskip("torch") +core = pytest.importorskip("autosampler.core") + +from autosampler.config import MSMConfig, SpawningConfig # noqa: E402 +from autosampler.msm import MSMEstimator, build_monitor_from_config # noqa: E402 + + +def _three_state_chain(n_steps, seed, p_escape=0.02): + rng = np.random.default_rng(seed) + centers = np.array([-2.0, 0.0, 2.0]) + P = np.array( + [ + [1 - p_escape, p_escape, 0.0], + [p_escape, 1 - 2 * p_escape, p_escape], + [0.0, p_escape, 1 - p_escape], + ] + ) + state = 0 + states = np.empty(n_steps, dtype=int) + for i in range(n_steps): + state = rng.choice(3, p=P[state]) + states[i] = state + x = centers[states] + rng.normal(scale=0.15, size=n_steps) + return x.reshape(-1, 1) + + +def _make_core(tmp_path, msm_cfg): + """Build a minimal AutoSamplerCore without running its MD-heavy __init__.""" + sampler = object.__new__(core.AutoSamplerCore) + sampler.config = types.SimpleNamespace( + msm=msm_cfg, + spawning=SpawningConfig(walker=4, step=2000, stride=10), + ) + sampler.outdir = tmp_path + sampler.history = {} + sampler.iteration = 0 + sampler.converged = False + sampler.convergence_reason = None + sampler.last_msm_result = None + sampler.msm_estimator = MSMEstimator( + lagtime=msm_cfg.lagtime, + n_microstates=msm_cfg.n_microstates, + n_metastable=msm_cfg.n_metastable, + n_timescales=msm_cfg.n_timescales, + seed=42, + ) + sampler.msm_monitor = build_monitor_from_config(msm_cfg) + return sampler + + +def _populate_history(sampler, n_iters, frames_per_walker, walkers, seed=0): + """Fill history with per-iteration projections shaped like a real run.""" + for it in range(n_iters): + chunks = [ + _three_state_chain(frames_per_walker, seed=seed + it * walkers + w) + for w in range(walkers) + ] + projection = np.vstack(chunks) + (sampler.outdir / f"iter_{it}").mkdir(parents=True, exist_ok=True) + sampler.history[it] = {"projection": projection} + sampler.iteration = n_iters + + +def test_collect_msm_trajectories_splits_per_walker(tmp_path): + cfg = MSMConfig(enabled=True, lagtime=5, n_microstates=20, min_frames=10) + sampler = _make_core(tmp_path, cfg) + _populate_history(sampler, n_iters=2, frames_per_walker=200, walkers=4) + + trajs = sampler._collect_msm_trajectories() + # 2 iterations x 4 walkers = 8 continuous trajectories. + assert len(trajs) == 8 + assert all(t.shape[0] == 200 for t in trajs) + + +def test_maybe_build_msm_estimates_and_saves(tmp_path): + cfg = MSMConfig( + enabled=True, + lagtime=5, + n_microstates=40, + n_metastable=3, + min_frames=500, + cadence=1, + ) + sampler = _make_core(tmp_path, cfg) + _populate_history(sampler, n_iters=3, frames_per_walker=500, walkers=4) + sampler.iteration = 3 # current_iteration -> 2 + + sampler._maybe_build_msm() + + assert sampler.last_msm_result is not None + assert sampler.last_msm_result.n_states_active >= 2 + assert (tmp_path / "iter_2" / "msm.npz").exists() + saved = np.load(tmp_path / "iter_2" / "msm.npz") + assert "timescales" in saved and "stationary_distribution" in saved + + +def test_maybe_build_msm_respects_min_frames(tmp_path): + cfg = MSMConfig(enabled=True, lagtime=5, n_microstates=20, min_frames=10_000) + sampler = _make_core(tmp_path, cfg) + _populate_history(sampler, n_iters=1, frames_per_walker=100, walkers=4) + sampler.iteration = 1 + + sampler._maybe_build_msm() + # Too few frames -> no MSM, no convergence side effects. + assert sampler.last_msm_result is None + assert sampler.converged is False + + +def test_maybe_build_msm_respects_cadence(tmp_path): + cfg = MSMConfig(enabled=True, lagtime=5, n_microstates=20, min_frames=100, cadence=5) + sampler = _make_core(tmp_path, cfg) + _populate_history(sampler, n_iters=2, frames_per_walker=400, walkers=4) + sampler.iteration = 2 # current_iteration = 1, 1 % 5 != 0 -> skipped + + sampler._maybe_build_msm() + assert sampler.last_msm_result is None diff --git a/tests/test_cv_methods.py b/tests/test_cv_methods.py new file mode 100644 index 0000000..b11da2e --- /dev/null +++ b/tests/test_cv_methods.py @@ -0,0 +1,99 @@ +"""Tests for the CV-method registry and the new VAMPNet / SPIB CV methods.""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +warnings.filterwarnings("ignore") + +from autosampler.spaces.registry import ( # noqa: E402 + FIXED_MODE, + adaptive_modes, + ensure_available, + get_method, + is_adaptive_space, + is_available, +) + + +def test_registry_lists_expected_methods(): + modes = set(adaptive_modes()) + for expected in {"pca", "tica", "tvae", "vampnet", "spib", "deep-tica", "deep-lda"}: + assert expected in modes + assert not is_adaptive_space(FIXED_MODE) + assert is_adaptive_space("vampnet") + + +def test_registry_metadata(): + assert get_method("deep-lda").supervised is True + assert get_method("spib").backend == "builtin" + assert get_method("vampnet").time_lagged is True + # Built-in / installed methods are available in the test environment. + assert is_available("spib") is True + assert is_available("pca") is True + + +def test_ensure_available_raises_for_missing_backend(): + # mlcolvar is an optional dependency, absent in the base test environment. + if not is_available("deep-tica"): + with pytest.raises(ImportError): + ensure_available("deep-tica") + + +def _three_state_chain(n_steps=12000, seed=0, p_escape=0.03): + rng = np.random.default_rng(seed) + centers = np.array([-2.0, 0.0, 2.0]) + P = np.array( + [ + [1 - p_escape, p_escape, 0.0], + [p_escape, 1 - 2 * p_escape, p_escape], + [0.0, p_escape, 1 - p_escape], + ] + ) + state = 0 + states = np.empty(n_steps, dtype=int) + for i in range(n_steps): + state = rng.choice(3, p=P[state]) + states[i] = state + # 4D features (only the first coordinate carries the slow signal). + feats = rng.normal(scale=0.2, size=(n_steps, 4)) + feats[:, 0] += centers[states] + return feats.astype(np.float32), states + + +pytest.importorskip("torch") +pytest.importorskip("deeptime") + +from autosampler.spaces.model import AdaptiveSpaceModel # noqa: E402 + + +@pytest.mark.parametrize("mode", ["vampnet", "spib"]) +def test_cv_method_trains_and_separates_states(mode): + feats, states = _three_state_chain() + model = AdaptiveSpaceModel( + space_mode=mode, + lagtime=5, + latent_dim=1, + epochs=8, + batch_size=512, + encoder_hidden_dims=[32, 16], + ) + model.fit(feats, walker_length=len(feats), n_walkers=1) + proj = model.project(feats) + assert proj.shape[0] == len(feats) + # The learned CV should correlate with the true slow state. + cv = np.asarray(proj)[:, 0] + corr = abs(np.corrcoef(cv, states)[0, 1]) + assert corr > 0.5, f"{mode} CV correlation with state too low: {corr:.2f}" + + +def test_deep_lda_raises_informative_error(): + feats, _ = _three_state_chain(n_steps=2000) + model = AdaptiveSpaceModel(space_mode="deep-lda", lagtime=5, latent_dim=1, epochs=2) + # Only reaches the NotImplementedError when mlcolvar is installed; otherwise + # the availability guard raises ImportError first. Either is acceptable. + with pytest.raises((NotImplementedError, ImportError)): + model.fit(feats, walker_length=len(feats), n_walkers=1) diff --git a/tests/test_execution.py b/tests/test_execution.py new file mode 100644 index 0000000..a2b681a --- /dev/null +++ b/tests/test_execution.py @@ -0,0 +1,218 @@ +"""Tests for the pluggable execution backends (local / SLURM / PBS). + +No real scheduler is needed: a fake command-runner runs the array tasks +synchronously in-process, exercising the full submit -> poll -> collect -> retry +state machine, script rendering, and job-id parsing. +""" + +from __future__ import annotations + +import warnings +from concurrent.futures import Future +from pathlib import Path +from types import SimpleNamespace + +import pytest + +warnings.filterwarnings("ignore") + +import autosampler.execution.run_task as run_task # noqa: E402 +from autosampler.engines.base import EngineFactory # noqa: E402 +from autosampler.execution import ( # noqa: E402 + ExecutionBackendFactory, + build_walker_tasks, + make_backend, +) +from autosampler.execution import local as local_mod # noqa: E402 +from autosampler.execution.pbs import PBSBackend # noqa: E402 +from autosampler.execution.slurm import SlurmBackend # noqa: E402 + + +# ── fake engine: directive carried via start_coords ───────────────────────── +class FakeEngine: + calls: dict = {} + + def __init__(self, **kwargs): + self.kwargs = kwargs + + def prepare(self, **kwargs): + pass + + def run_production( + self, run_index, start_coords, steps, traj_out, stride, device_index + ): + FakeEngine.calls[run_index] = FakeEngine.calls.get(run_index, 0) + 1 + Path(traj_out).parent.mkdir(parents=True, exist_ok=True) + Path(traj_out).write_bytes(b"x") + if start_coords == "fail": + return False + if start_coords == "transient": # fail first attempt, succeed on retry + return FakeEngine.calls[run_index] >= 2 + return True + + +EngineFactory.register("fake", FakeEngine) + + +@pytest.fixture(autouse=True) +def _reset_fake_calls(): + FakeEngine.calls.clear() + yield + + +def _tasks(tmp_path, walkers): + return build_walker_tasks( + engine_name="fake", + engine_kwargs={}, + prepare_kwargs={}, + walkers=walkers, + steps=10, + stride=1, + outdir=tmp_path / "iter_0", + iteration=0, + ) + + +# ── task building ─────────────────────────────────────────────────────────── +def test_build_walker_tasks_names_and_payload(tmp_path): + tasks = _tasks(tmp_path, ["ok", "ok"]) + assert [t.index for t in tasks] == [0, 1] + assert tasks[0].traj_out.endswith("iter_0/iteration_0_0.xtc") + assert tasks[1].start_coords == "ok" + assert tasks[0].run_kwargs()["steps"] == 10 + + +# ── local backend (synchronous executor to avoid spawn pickling) ──────────── +class _SyncExecutor: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def submit(self, fn, *args): + fut: Future = Future() + try: + fut.set_result(fn(*args)) + except Exception as exc: # noqa: BLE001 + fut.set_exception(exc) + return fut + + +def test_local_backend_runs_all_walkers(tmp_path, monkeypatch): + monkeypatch.setattr(local_mod, "ProcessPoolExecutor", _SyncExecutor) + backend = ExecutionBackendFactory.get("local", gpu_ids=[0], max_workers=2) + results = backend.execute(_tasks(tmp_path, ["ok", "fail", "ok"])) + assert results == [True, False, True] + + +# ── fake scheduler command runner ─────────────────────────────────────────── +def _fake_runner(submit_id="4242"): + def runner(cmd, timeout): + prog = cmd[0] + if prog in ("sbatch", "qsub"): + script = Path(cmd[-1]) + manifest = None + for line in script.read_text().splitlines(): + if line.startswith("MANIFEST="): + manifest = Path(line.split("=", 1)[1].strip().strip('"')) + assert manifest is not None + for entry in manifest.read_text().splitlines(): + entry = entry.strip() + if entry: + task_pkl, result_json = entry.split() + run_task.main([task_pkl, result_json]) + return SimpleNamespace(returncode=0, stdout=submit_id, stderr="") + # squeue / qstat: report no active jobs (markers already written). + return SimpleNamespace(returncode=0, stdout="", stderr="") + + return runner + + +@pytest.mark.parametrize("backend_cls", [SlurmBackend, PBSBackend]) +def test_scheduler_backend_runs_and_collects(tmp_path, backend_cls): + backend = backend_cls( + command_runner=_fake_runner(), + sleep_fn=lambda s: None, + max_retries=0, + python_executable="python", + ) + results = backend.execute(_tasks(tmp_path, ["ok", "fail", "ok"])) + assert results == [True, False, True] + # Job artifacts written beside the iteration outputs. + assert (tmp_path / "iter_0" / "_jobs" / "manifest_attempt0.txt").exists() + assert (tmp_path / "iter_0" / "_jobs" / "submit_attempt0.sh").exists() + + +def test_scheduler_backend_retries_transient_failure(tmp_path): + backend = SlurmBackend( + command_runner=_fake_runner(), + sleep_fn=lambda s: None, + max_retries=1, + python_executable="python", + ) + results = backend.execute(_tasks(tmp_path, ["ok", "transient"])) + assert results == [True, True] # transient succeeds on the retry attempt + # Retry produced a second attempt manifest. + assert (tmp_path / "iter_0" / "_jobs" / "manifest_attempt1.txt").exists() + + +def test_scheduler_submit_failure_raises(tmp_path): + def failing_runner(cmd, timeout): + if cmd[0] in ("sbatch", "qsub"): + return SimpleNamespace(returncode=1, stdout="", stderr="boom") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + backend = SlurmBackend(command_runner=failing_runner, sleep_fn=lambda s: None) + with pytest.raises(RuntimeError, match="submission failed"): + backend.execute(_tasks(tmp_path, ["ok"])) + + +# ── script rendering & job-id parsing ─────────────────────────────────────── +def test_slurm_script_directives(tmp_path): + backend = SlurmBackend( + partition="gpu", account="proj", gpus_per_task=1, walltime="02:00:00" + ) + script = backend._render_script(4, tmp_path / "m.txt", tmp_path / "logs") + assert "#SBATCH --array=0-3" in script + assert "#SBATCH --partition=gpu" in script + assert "#SBATCH --gpus-per-task=1" in script + assert "#SBATCH --time=02:00:00" in script + assert "SLURM_ARRAY_TASK_ID" in script + assert "autosampler.execution.run_task" in script + + +def test_pbs_script_directives(tmp_path): + backend = PBSBackend(cpus_per_task=4, gpus_per_task=2, memory="8G") + script = backend._render_script(3, tmp_path / "m.txt", tmp_path / "logs") + assert "#PBS -J 0-2" in script + assert "ncpus=4" in script and "ngpus=2" in script and "mem=8G" in script + assert "PBS_ARRAY_INDEX" in script + + +def test_slurm_parse_job_id(): + backend = SlurmBackend() + assert backend._parse_job_id("123456\n") == "123456" + assert backend._parse_job_id("123456;cluster\n") == "123456" + + +def test_pbs_parse_job_id(): + backend = PBSBackend() + assert backend._parse_job_id("789[].pbs01\n") == "789[].pbs01" + + +# ── make_backend dispatch ─────────────────────────────────────────────────── +def test_make_backend_dispatch(): + from autosampler.config import ExecutionConfig + + assert make_backend(None).__class__.__name__ == "LocalProcessBackend" + assert ( + make_backend(ExecutionConfig(backend="local")).__class__.__name__ + == "LocalProcessBackend" + ) + slurm = make_backend(ExecutionConfig(backend="slurm", partition="gpu")) + assert slurm.__class__.__name__ == "SlurmBackend" + assert slurm.partition == "gpu" diff --git a/tests/test_feature_selection.py b/tests/test_feature_selection.py new file mode 100644 index 0000000..ef22b1a --- /dev/null +++ b/tests/test_feature_selection.py @@ -0,0 +1,113 @@ +"""Tests for VAMP-2 input-feature selection.""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +warnings.filterwarnings("ignore") + +from autosampler.config import FeatureSelectionConfig # noqa: E402 +from autosampler.spaces.feature_selection import ( # noqa: E402 + FeatureSelector, + greedy_vamp_selection, + rank_candidates, + vamp2_score, +) + + +def _slow_plus_noise(n_steps=8000, seed=0, p_escape=0.02, n_noise=4): + """Column 0 carries slow 2-state dynamics; remaining columns are noise.""" + rng = np.random.default_rng(seed) + state = 0 + states = np.empty(n_steps, dtype=int) + for i in range(n_steps): + if rng.random() < p_escape: + state = 1 - state + states[i] = state + slow = np.where(states == 0, -1.0, 1.0) + rng.normal(scale=0.1, size=n_steps) + noise = rng.normal(size=(n_steps, n_noise)) + return np.column_stack([slow, noise]).astype(np.float64) + + +def test_vamp2_score_prefers_slow_feature(): + traj = _slow_plus_noise() + slow_only = [traj[:, [0]]] + noise_only = [traj[:, 1:]] + assert vamp2_score(slow_only, lagtime=10) > vamp2_score(noise_only, lagtime=10) + + +def test_vamp2_score_raises_on_short_traj(): + with pytest.raises(ValueError): + vamp2_score([np.zeros((5, 2))], lagtime=10) + + +def test_rank_candidates_orders_by_score(): + traj = _slow_plus_noise() + ranked = rank_candidates( + {"slow": [traj[:, [0]]], "noise": [traj[:, 1:]]}, lagtime=10 + ) + assert ranked[0][0] == "slow" + assert ranked[0][1] >= ranked[1][1] + + +def test_greedy_selection_is_parsimonious(): + traj = _slow_plus_noise() + cols = greedy_vamp_selection([traj], lagtime=10, min_gain=1e-3) + # Picks the informative column and rejects the noise columns (VAMP-2 is + # monotonic in #features, so parsimony comes from the min_gain threshold). + assert cols == [0] + assert len(cols) < traj.shape[1] + # The parsimonious subset retains essentially all of the kinetic variance. + assert vamp2_score([traj[:, cols]], 10) >= 0.95 * vamp2_score([traj], 10) + + +def test_feature_selector_select_and_serialise(): + traj = _slow_plus_noise() + selector = FeatureSelector(lagtime=10, method="greedy_vamp", min_gain=1e-3) + selection = selector.select([traj]) + assert 0 in selection.columns + from autosampler.spaces.feature_selection import FeatureSelection + + restored = FeatureSelection.from_dict(selection.to_dict()) + assert restored.columns == selection.columns + + +def test_feature_selector_method_all(): + traj = _slow_plus_noise(n_noise=3) + selector = FeatureSelector(method="all") + selection = selector.select([traj]) + assert selection.columns == [0, 1, 2, 3] + + +def test_feature_selection_config_validation(): + from pydantic import ValidationError + + cfg = FeatureSelectionConfig(enabled=True, method="greedy_vamp", lagtime=5) + assert cfg.enabled and cfg.cadence == 5 + with pytest.raises(ValidationError): + FeatureSelectionConfig(method="banana") + with pytest.raises(ValidationError): + FeatureSelectionConfig(lagtime=0) + + +def test_candidate_feature_types_validation(): + from pydantic import ValidationError + + cfg = FeatureSelectionConfig( + enabled=True, candidate_feature_types=["distances", "phi_psi"] + ) + assert cfg.candidate_feature_types == ["distances", "phi_psi"] + with pytest.raises(ValidationError): + FeatureSelectionConfig(candidate_feature_types=["distances", "nope"]) + + +def test_rank_candidates_selects_best_feature_type(): + # Simulate two feature *types*: one resolves the slow process, one is noise. + traj = _slow_plus_noise() + ranked = rank_candidates( + {"distances": [traj[:, [0]]], "fitted_coords": [traj[:, 1:]]}, lagtime=10 + ) + assert ranked[0][0] == "distances" diff --git a/tests/test_hardening.py b/tests/test_hardening.py new file mode 100644 index 0000000..dff9a68 --- /dev/null +++ b/tests/test_hardening.py @@ -0,0 +1,103 @@ +"""Tests for Phase 2 hardening: reporter, checkpoint versioning, trajectory +validation, MD subprocess timeout, and the fited->fitted compatibility shim.""" + +from __future__ import annotations + +import warnings + +import pytest + +warnings.filterwarnings("ignore") + +from autosampler.engines.base import md_subprocess_timeout # noqa: E402 +from autosampler.reporting import IterationReporter # noqa: E402 + + +def test_reporter_plain_and_colored(): + reporter = IterationReporter(width=40) + plain = reporter.format_summary(3, 1.2, 0.5, "10/100", color=False) + assert "Iteration: 3" in plain + assert "\033[" not in plain # no ANSI escapes when color disabled + assert "╔" in plain and "╝" in plain + + colored = reporter.format_summary(3, 1.2, 0.5, "10/100", color=True) + assert "\033[96m" in colored # cyan applied + + +def test_md_subprocess_timeout(monkeypatch): + monkeypatch.delenv("AUTOSAMPLER_MD_TIMEOUT", raising=False) + assert md_subprocess_timeout() is None + monkeypatch.setenv("AUTOSAMPLER_MD_TIMEOUT", "120") + assert md_subprocess_timeout() == 120.0 + monkeypatch.setenv("AUTOSAMPLER_MD_TIMEOUT", "0") + assert md_subprocess_timeout() is None # non-positive ignored + monkeypatch.setenv("AUTOSAMPLER_MD_TIMEOUT", "not-a-number") + assert md_subprocess_timeout() is None + + +def test_checkpoint_format_version(tmp_path): + pytest.importorskip("torch") + from autosampler.checkpoints.manager import ( + CHECKPOINT_FORMAT_VERSION, + CheckpointManager, + ) + + mgr = CheckpointManager(str(tmp_path)) + mgr.save( + iteration=0, + space_model=None, + scaler={"k": 1}, + bin_state={}, + history={}, + sampler_state={"x": 1}, + ) + version_file = tmp_path / "iter_0" / "format_version" + assert version_file.exists() + assert int(version_file.read_text()) == CHECKPOINT_FORMAT_VERSION + + # Round-trips without raising and reads the stored state back. + _, scaler, _, _, sampler_state = mgr.load(0) + assert scaler == {"k": 1} + assert sampler_state == {"x": 1} + + +def test_fited_to_fitted_pickle_shim(): + pytest.importorskip("torch") + pytest.importorskip("deeptime") + from autosampler.spaces.model import AdaptiveSpaceModel + + model = AdaptiveSpaceModel(space_mode="pca") + # Simulate a legacy pickle state that used the misspelled attribute. + state = dict(model.__dict__) + state["fited"] = state.pop("fitted") + restored = AdaptiveSpaceModel.__new__(AdaptiveSpaceModel) + restored.__setstate__(state) + assert hasattr(restored, "fitted") + assert "fited" not in restored.__dict__ + + +def test_validate_trajectory_files(tmp_path): + pytest.importorskip("openmm") + pytest.importorskip("MDAnalysis") + from autosampler.core import AutoSamplerCore + + good = tmp_path / "ok.xtc" + good.write_bytes(b"\x00\x01\x02") + empty = tmp_path / "empty.xtc" + empty.write_bytes(b"") + missing = tmp_path / "missing.xtc" + + # All good -> no error. + AutoSamplerCore._validate_trajectory_files([str(good)]) + # Empty or missing -> RuntimeError listing the offenders. + with pytest.raises(RuntimeError, match="empty"): + AutoSamplerCore._validate_trajectory_files([str(good), str(empty)]) + with pytest.raises(RuntimeError, match="missing"): + AutoSamplerCore._validate_trajectory_files([str(missing)]) + + +def test_no_weresampler_import(): + # The dead WEResampler stub and its export were removed in Phase 2. + import autosampler.binning as binning + + assert not hasattr(binning, "WEResampler") diff --git a/tests/test_input_template.py b/tests/test_input_template.py new file mode 100644 index 0000000..7d94e64 --- /dev/null +++ b/tests/test_input_template.py @@ -0,0 +1,47 @@ +"""Tests for the input-file template and the autosampler-init CLI.""" + +from __future__ import annotations + +import warnings +from pathlib import Path + +import pytest +import yaml + +warnings.filterwarnings("ignore") + +from autosampler.config import AutoSamplerConfig # noqa: E402 +from autosampler.templates import DEFAULT_TEMPLATE # noqa: E402 + + +def test_template_parses_into_config(): + cfg = AutoSamplerConfig(**yaml.safe_load(DEFAULT_TEMPLATE)) + # Spot-check a value from each major block. + assert cfg.system.conf_file + assert cfg.engine.md_engine in {"openmm", "gromacs", "amber"} + assert cfg.spawning.spawn_scheme in { + "density", "voronoi", "lof", "fps", "msm", "we" + } + assert cfg.execution.backend == "local" + assert cfg.msm.enabled is False # advanced blocks opt-in by default + + +def test_example_template_matches_module(): + # The committed examples/template.yaml must equal the packaged template. + repo_root = Path(__file__).resolve().parents[1] + text = (repo_root / "examples" / "template.yaml").read_text() + assert text == DEFAULT_TEMPLATE + + +def test_init_cli_writes_and_guards(tmp_path): + from autosampler.init_cli import main + + out = tmp_path / "config.yaml" + main(["-o", str(out)]) + assert out.exists() + assert AutoSamplerConfig(**yaml.safe_load(out.read_text())) is not None + + # Refuses to overwrite without --force, succeeds with it. + with pytest.raises(SystemExit): + main(["-o", str(out)]) + main(["-o", str(out), "--force"]) diff --git a/tests/test_msm.py b/tests/test_msm.py new file mode 100644 index 0000000..5f7ae0f --- /dev/null +++ b/tests/test_msm.py @@ -0,0 +1,156 @@ +"""Tests for the MSM subsystem: estimator, diagnostics, convergence monitor.""" + +from __future__ import annotations + +import numpy as np +import pytest + +deeptime = pytest.importorskip("deeptime") + +from autosampler.msm import ( # noqa: E402 + ConvergenceMonitor, + ImpliedTimescaleCriterion, + MSMEstimator, + MSMResult, + VAMP2Criterion, + build_criterion, +) + + +def _three_state_chain(n_steps=40000, seed=0, p_escape=0.02): + """Generate a 1D trajectory of a metastable 3-state Markov chain. + + States sit at x = -2, 0, +2 with Gaussian emission so the CV space is + continuous and must be re-clustered (as in a real run). + """ + rng = np.random.default_rng(seed) + centers = np.array([-2.0, 0.0, 2.0]) + P = np.array( + [ + [1 - p_escape, p_escape, 0.0], + [p_escape, 1 - 2 * p_escape, p_escape], + [0.0, p_escape, 1 - p_escape], + ] + ) + state = 0 + states = np.empty(n_steps, dtype=int) + for i in range(n_steps): + state = rng.choice(3, p=P[state]) + states[i] = state + x = centers[states] + rng.normal(scale=0.15, size=n_steps) + return x.reshape(-1, 1) + + +def test_estimator_recovers_three_states(): + traj = _three_state_chain() + est = MSMEstimator(lagtime=5, n_microstates=50, n_metastable=3, n_timescales=2) + result = est.fit([traj]) + + assert isinstance(result, MSMResult) + assert result.n_states_active >= 2 + # Two slow processes resolved and positive. + assert np.all(result.timescales[np.isfinite(result.timescales)] > 0) + # Stationary distribution is a probability vector. + assert result.stationary_distribution.sum() == pytest.approx(1.0, abs=1e-6) + # PCCA+ recovered three metastable populations that sum to ~1. + assert result.n_metastable == 3 + assert result.metastable_populations.sum() == pytest.approx(1.0, abs=1e-6) + assert result.vamp2_score is not None and result.vamp2_score > 1.0 + + +def test_estimator_implied_timescales_sweep(): + traj = _three_state_chain() + est = MSMEstimator( + lagtime=5, n_microstates=40, lagtimes=[1, 2, 5, 10], n_timescales=2 + ) + result = est.fit([traj]) + assert result.its is not None + assert result.its.lagtimes.size >= 2 + assert result.its.timescales.shape[0] == result.its.lagtimes.size + + +def test_estimator_serialisation_roundtrip(): + traj = _three_state_chain(n_steps=20000) + est = MSMEstimator(lagtime=5, n_microstates=30, n_metastable=2) + result = est.fit([traj]) + restored = MSMResult.from_dict(result.to_dict()) + assert restored.lagtime == result.lagtime + np.testing.assert_allclose(restored.timescales, result.timescales) + np.testing.assert_allclose( + restored.stationary_distribution, result.stationary_distribution + ) + + +def test_estimator_raises_on_empty(): + est = MSMEstimator(lagtime=5, n_microstates=10) + with pytest.raises(ValueError): + est.fit([]) + + +def _result(timescales, vamp2=None, populations=None): + n = len(timescales) + return MSMResult( + lagtime=5, + n_microstates=10, + n_states_active=n + 1, + timescales=np.asarray(timescales, dtype=float), + stationary_distribution=np.ones(n + 1) / (n + 1), + transition_matrix=np.eye(n + 1), + cluster_centers=np.zeros((n + 1, 1)), + counts_per_state=np.ones(n + 1), + vamp2_score=vamp2, + n_metastable=None if populations is None else len(populations), + metastable_populations=None if populations is None else np.asarray(populations), + ) + + +def test_its_criterion_detects_plateau_and_drift(): + crit = ImpliedTimescaleCriterion(tol=0.05, n_timescales=1) + assert crit.update(_result([100.0])).satisfied is False # baseline + assert crit.update(_result([101.0])).satisfied is True # 1% change < 5% + assert crit.update(_result([130.0])).satisfied is False # 28% change > 5% + + +def test_vamp2_criterion(): + crit = VAMP2Criterion(tol=0.02) + assert crit.update(_result([100.0], vamp2=2.0)).satisfied is False + assert crit.update(_result([100.0], vamp2=2.01)).satisfied is True + assert crit.update(_result([100.0], vamp2=2.5)).satisfied is False + + +def test_monitor_converges_after_patience(): + monitor = ConvergenceMonitor( + [ImpliedTimescaleCriterion(tol=0.05, n_timescales=1)], + mode="all", + patience=2, + ) + assert monitor.update(_result([100.0])) is False # baseline, not satisfied + assert monitor.update(_result([100.5])) is False # satisfied once (streak 1) + assert monitor.update(_result([100.7])) is True # satisfied twice -> converged + + +def test_monitor_does_not_converge_on_drift(): + monitor = ConvergenceMonitor( + [ImpliedTimescaleCriterion(tol=0.02, n_timescales=1)], + mode="all", + patience=2, + ) + converged = False + for ts in [100.0, 140.0, 90.0, 160.0, 70.0]: + converged = monitor.update(_result([ts])) + assert converged is False + + +def test_monitor_state_roundtrip(): + monitor = ConvergenceMonitor([VAMP2Criterion(tol=0.1)], patience=3) + monitor.update(_result([100.0], vamp2=1.0)) + monitor.update(_result([100.0], vamp2=1.01)) + state = monitor.state_dict() + other = ConvergenceMonitor([VAMP2Criterion(tol=0.1)], patience=3) + other.load_state_dict(state) + assert other.streak == monitor.streak + + +def test_build_criterion_unknown(): + with pytest.raises(ValueError): + build_criterion("does_not_exist") diff --git a/tests/test_msm_tmatrix.py b/tests/test_msm_tmatrix.py new file mode 100644 index 0000000..45c5704 --- /dev/null +++ b/tests/test_msm_tmatrix.py @@ -0,0 +1,158 @@ +"""Tests for the MSM transition-matrix convergence criterion and the +uncertainty x leverage x flux MSM-guided spawner.""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +warnings.filterwarnings("ignore") + +from autosampler.msm import TransitionMatrixCriterion # noqa: E402 +from autosampler.msm.diagnostics import MSMResult # noqa: E402 +from autosampler.spawners import SpawnerFactory # noqa: E402 + + +def _result(T, pi, counts, *, eigenvectors=None, symbols=None): + T = np.asarray(T, dtype=float) + n = T.shape[0] + counts = np.asarray(counts, dtype=float) + count_matrix = T * counts[:, None] # row i sums to counts[i] + return MSMResult( + lagtime=5, + n_microstates=n, + n_states_active=n, + timescales=np.array([10.0]), + stationary_distribution=np.asarray(pi, dtype=float), + transition_matrix=T, + cluster_centers=np.zeros((n, 1)), + counts_per_state=counts, + count_matrix=count_matrix, + eigenvectors=None if eigenvectors is None else np.asarray(eigenvectors, float), + state_symbols=np.arange(n) if symbols is None else np.asarray(symbols, int), + ) + + +# ── TransitionMatrixCriterion ─────────────────────────────────────────────── +def test_tmatrix_criterion_satisfied_when_well_sampled(): + T = np.array([[0.9, 0.1], [0.1, 0.9]]) + crit = TransitionMatrixCriterion(tol=0.2) + status = crit.update(_result(T, [0.5, 0.5], [10000, 10000])) + assert status.satisfied is True + assert status.value < 0.2 + + +def test_tmatrix_criterion_unsatisfied_when_sparse(): + T = np.array([[0.9, 0.1], [0.1, 0.9]]) + crit = TransitionMatrixCriterion(tol=0.2) + status = crit.update(_result(T, [0.5, 0.5], [10, 10])) + assert status.satisfied is False + assert status.value > 0.2 + + +def test_tmatrix_criterion_requires_count_matrix(): + crit = TransitionMatrixCriterion() + res = _result(np.eye(2), [0.5, 0.5], [100, 100]) + res.count_matrix = None + status = crit.update(res) + assert status.satisfied is False + assert "count_matrix" in status.detail + + +def test_tmatrix_criterion_flux_mask_ignores_tiny_entries(): + # A noisy, barely-visited transition with negligible stationary flux must not + # block convergence when the high-flux transitions are well determined. + T = np.array( + [[0.90, 0.10, 0.00], [0.10, 0.90, 0.00], [0.30, 0.30, 0.40]] + ) + pi = np.array([0.499, 0.499, 0.002]) # state 2 has negligible weight + counts = np.array([100000, 100000, 8]) # state 2 poorly sampled + crit = TransitionMatrixCriterion(tol=0.1, min_flux=1e-2) + assert crit.update(_result(T, pi, counts)).satisfied is True + + +# ── MSM-guided spawner ────────────────────────────────────────────────────── +class _FakeClusterModel: + """Assigns a 1-D point x to microstate floor(x) in [0, 2].""" + + def transform(self, X): + return np.clip(np.floor(np.asarray(X)[:, 0]).astype(int), 0, 2) + + +def _three_microstate_result(): + T = np.array( + [[0.98, 0.01, 0.01], [0.30, 0.40, 0.30], [0.01, 0.01, 0.98]] + ) + pi = np.array([0.4, 0.2, 0.4]) + counts = np.array([1000.0, 10.0, 1000.0]) # state 1 poorly sampled (high σ) + eig = np.array([[0.1], [1.0], [0.1]]) # state 1 high leverage + return _result(T, pi, counts, eigenvectors=eig) + + +def test_msm_guided_weights_concentrate_on_uncertain_high_leverage_state(): + spawner = SpawnerFactory.get("msm", alpha=1.0, leverage=1, uncertainty=True, seed=0) + spawner.msm_result = _three_microstate_result() + spawner.cluster_model = _FakeClusterModel() + + x = np.concatenate([0.5 * np.ones(10), 1.5 * np.ones(10), 2.5 * np.ones(10)]) + cumulative = x.reshape(-1, 1) + w = spawner._msm_guided_weights(cumulative) + assert w is not None + s0, s1, s2 = w[:10].mean(), w[10:20].mean(), w[20:30].mean() + # The uncertain, high-leverage microstate (1) gets the most weight per frame. + assert s1 > s0 and s1 > s2 + + +def test_msm_guided_falls_back_to_least_counts_without_msm(): + spawner = SpawnerFactory.get("msm", seed=0) + assert spawner.msm_result is None + pts = np.random.default_rng(0).normal(size=(50, 2)) + idx = spawner.sample(pts, top_n=8) + assert len(idx) == 8 + assert all(0 <= i < 50 for i in idx) + + +def test_msm_guided_sample_returns_valid_indices(): + spawner = SpawnerFactory.get("msm", alpha=1.0, leverage=1, seed=1) + spawner.msm_result = _three_microstate_result() + spawner.cluster_model = _FakeClusterModel() + x = np.concatenate([0.5 * np.ones(10), 1.5 * np.ones(10), 2.5 * np.ones(10)]) + idx = spawner.sample(x.reshape(-1, 1), top_n=6) + assert len(idx) == 6 and all(0 <= i < 30 for i in idx) + + +# ── Estimator populates the new fields ────────────────────────────────────── +def _three_state_chain(n=20000, p=0.02, seed=0): + rng = np.random.default_rng(seed) + centers = np.array([-2.0, 0.0, 2.0]) + P = np.array([[1 - p, p, 0], [p, 1 - 2 * p, p], [0, p, 1 - p]]) + s, states = 0, np.empty(n, int) + for i in range(n): + s = rng.choice(3, p=P[s]) + states[i] = s + return (centers[states] + rng.normal(scale=0.15, size=n)).reshape(-1, 1) + + +def test_estimator_populates_tmatrix_fields(): + pytest.importorskip("deeptime") + from autosampler.msm import MSMEstimator + + est = MSMEstimator(lagtime=5, n_microstates=30, n_metastable=3, n_timescales=2) + res = est.fit([_three_state_chain()]) + assert res.count_matrix is not None + assert res.state_symbols is not None + assert res.count_matrix.shape[0] == res.n_states_active + assert res.eigenvectors is None or res.eigenvectors.shape[0] == res.n_states_active + + +def test_stable_clustering_runs_and_keeps_cluster_count(): + pytest.importorskip("deeptime") + from autosampler.msm import MSMEstimator + + est = MSMEstimator(lagtime=5, n_microstates=25, stable_clustering=True) + traj = _three_state_chain() + r1 = est.fit([traj]) + r2 = est.fit([np.vstack([traj, _three_state_chain(seed=1)])]) + assert r1.n_microstates == r2.n_microstates # stable ID space across fits diff --git a/tests/test_retraining.py b/tests/test_retraining.py new file mode 100644 index 0000000..060317f --- /dev/null +++ b/tests/test_retraining.py @@ -0,0 +1,104 @@ +"""Tests for the adaptive CV-retraining controller and reproducible seeding.""" + +from __future__ import annotations + +import warnings + +import pytest + +warnings.filterwarnings("ignore") + +from autosampler.spaces.retraining import RetrainController # noqa: E402 + + +def test_fixed_policy_matches_legacy_schedule(): + ctrl = RetrainController(policy="fixed", retrain_freq=3) + # No model yet -> always retrain. + assert ctrl.should_retrain(0, has_model=False) is True + # With a model, retrain only when iteration % freq == 0. + assert ctrl.should_retrain(3, has_model=True) is True + assert ctrl.should_retrain(4, has_model=True) is False + assert ctrl.should_retrain(6, has_model=True) is True + + +def test_fixed_policy_freq_zero_never_retrains_with_model(): + ctrl = RetrainController(policy="fixed", retrain_freq=0) + assert ctrl.should_retrain(5, has_model=True) is False + + +def test_vamp_adaptive_retrains_on_score_drop(): + ctrl = RetrainController(policy="vamp_adaptive", vamp_tol=0.1, min_interval=1) + # First training establishes the reference. + assert ctrl.should_retrain(0, has_model=False) is True + ctrl.notify_retrained(new_score=2.0) + # Score holds -> no retrain. + ctrl.notify_skipped() + assert ctrl.should_retrain(1, has_model=True, current_score=1.95) is False + # Score drops >10% -> retrain, with an informative reason. + ctrl.notify_skipped() + assert ctrl.should_retrain(2, has_model=True, current_score=1.5) is True + assert "VAMP-2 dropped" in ctrl.last_reason + + +def test_vamp_adaptive_respects_min_and_max_interval(): + ctrl = RetrainController( + policy="vamp_adaptive", vamp_tol=0.1, min_interval=2, max_interval=4 + ) + ctrl.should_retrain(0, has_model=False) + ctrl.notify_retrained(new_score=2.0) + # min_interval=2: a big drop is ignored until enough iterations pass. + ctrl.notify_skipped() # iters_since_retrain = 1 + assert ctrl.should_retrain(1, has_model=True, current_score=0.5) is False + ctrl.notify_skipped() # = 2 + assert ctrl.should_retrain(2, has_model=True, current_score=0.5) is True + + # max_interval=4: force a refresh even if the score is stable. + ctrl.notify_retrained(new_score=2.0) + for _ in range(4): + ctrl.notify_skipped() + assert ctrl.should_retrain(10, has_model=True, current_score=2.0) is True + assert "max interval" in ctrl.last_reason + + +def test_controller_state_roundtrip(): + ctrl = RetrainController(policy="vamp_adaptive") + ctrl.notify_retrained(new_score=1.5) + ctrl.notify_skipped() + state = ctrl.state_dict() + other = RetrainController(policy="vamp_adaptive") + other.load_state_dict(state) + assert other.reference_score == 1.5 + assert other.iters_since_retrain == ctrl.iters_since_retrain + + +def test_invalid_policy_raises(): + with pytest.raises(ValueError): + RetrainController(policy="banana") + + +def test_retrain_policy_config_validation(): + from pydantic import ValidationError + + from autosampler.config import AutoSamplerConfig + + base = { + "system": {"conf_file": "a", "top_file": "b"}, + "engine": {}, + "spawning": {}, + } + cfg = AutoSamplerConfig(**base, retrain_policy="vamp_adaptive") + assert cfg.retrain_policy == "vamp_adaptive" + with pytest.raises(ValidationError): + AutoSamplerConfig(**base, retrain_policy="banana") + + +def test_seed_manager_is_deterministic(): + import numpy as np + + from autosampler.utils.seeds import SeedManager + + SeedManager(123).set_seed() + a = np.random.rand(5) + SeedManager(123).set_seed() + b = np.random.rand(5) + np.testing.assert_array_equal(a, b) diff --git a/tests/test_weighted_ensemble.py b/tests/test_weighted_ensemble.py new file mode 100644 index 0000000..be2970a --- /dev/null +++ b/tests/test_weighted_ensemble.py @@ -0,0 +1,85 @@ +"""Tests for weighted-ensemble resampling: split/merge weight conservation.""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +warnings.filterwarnings("ignore") + +from autosampler.binning.we import WeightedEnsemble # noqa: E402 +from autosampler.spawners import SpawnerFactory # noqa: E402 + + +def test_split_low_population_bin(): + we = WeightedEnsemble(target_per_bin=4) + # One walker in a single bin, weight 1.0 -> split into 4 of 0.25. + res = we.resample([1.0], [0], rng=np.random.default_rng(0)) + assert len(res) == 4 + assert all(p == 0 for p in res.parents) + assert sum(res.weights) == pytest.approx(1.0) + assert all(w == pytest.approx(0.25) for w in res.weights) + + +def test_merge_high_population_bin(): + we = WeightedEnsemble(target_per_bin=1) + res = we.resample( + [0.1, 0.1, 0.1, 0.7], [0, 0, 0, 0], rng=np.random.default_rng(0) + ) + assert len(res) == 1 + assert sum(res.weights) == pytest.approx(1.0) # weight conserved + + +def test_weight_conserved_across_multiple_bins(): + we = WeightedEnsemble(target_per_bin=3) + rng = np.random.default_rng(1) + weights = rng.random(20) + labels = rng.integers(0, 4, size=20) + res = we.resample(weights, labels, rng=rng) + # Total weight conserved and each occupied bin has exactly target walkers. + assert sum(res.weights) == pytest.approx(weights.sum()) + parent_labels = labels[np.asarray(res.parents)] + for b in np.unique(labels): + assert int(np.sum(parent_labels == b)) == 3 + + +def test_no_change_when_already_at_target(): + we = WeightedEnsemble(target_per_bin=2) + res = we.resample([0.5, 0.5], [0, 0], rng=np.random.default_rng(0)) + assert len(res) == 2 + assert sorted(res.parents) == [0, 1] + assert sum(res.weights) == pytest.approx(1.0) + + +def test_invalid_target(): + with pytest.raises(ValueError): + WeightedEnsemble(target_per_bin=0) + we = WeightedEnsemble(target_per_bin=2) + with pytest.raises(ValueError): + we.resample([1.0, 1.0], [0], rng=np.random.default_rng(0)) # length mismatch + + +def test_we_spawner_returns_valid_indices(): + spawner = SpawnerFactory.get( + "we", n_bins=[5, 5], target_per_bin=3, seed=0 + ) + rng = np.random.default_rng(0) + points = rng.normal(size=(60, 2)) + idx = spawner.sample(points, top_n=8) + assert len(idx) == 8 + assert all(0 <= i < len(points) for i in idx) + # Weights are tracked and normalised for the next iteration. + assert spawner.weights is not None + assert spawner.weights.sum() == pytest.approx(1.0) + + +def test_we_spawner_state_roundtrip(): + spawner = SpawnerFactory.get("we", n_bins=[5, 5], target_per_bin=2, seed=0) + points = np.random.default_rng(1).normal(size=(40, 2)) + spawner.sample(points, top_n=5) + state = spawner.state_dict() + other = SpawnerFactory.get("we", n_bins=[5, 5], target_per_bin=2, seed=0) + other.load_state_dict(state) + np.testing.assert_allclose(other.weights, spawner.weights) diff --git a/tools/generate_changelog_pdf.py b/tools/generate_changelog_pdf.py new file mode 100644 index 0000000..eb485c1 --- /dev/null +++ b/tools/generate_changelog_pdf.py @@ -0,0 +1,373 @@ +"""Generate a structured PDF changelog for AutoSampler.""" +import os + +from reportlab.lib import colors +from reportlab.lib.enums import TA_LEFT +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.platypus import ( + HRFlowable, + ListFlowable, + ListItem, + PageBreak, + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, +) + +OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "AutoSampler_Changelog.pdf") + +NAVY = colors.HexColor("#1A237E") +INDIGO = colors.HexColor("#3949AB") +GREEN = colors.HexColor("#2E7D32") +LGREY = colors.HexColor("#ECEFF1") +DGREY = colors.HexColor("#37474F") + +ss = getSampleStyleSheet() +H1 = ParagraphStyle("H1", parent=ss["Heading1"], textColor=NAVY, fontSize=16, + spaceBefore=14, spaceAfter=6) +H2 = ParagraphStyle("H2", parent=ss["Heading2"], textColor=INDIGO, fontSize=12.5, + spaceBefore=10, spaceAfter=4) +BODY = ParagraphStyle("Body", parent=ss["BodyText"], fontSize=9.7, leading=14, + alignment=TA_LEFT, spaceAfter=4) +SMALL = ParagraphStyle("Small", parent=BODY, fontSize=8.6, textColor=DGREY) +TITLE = ParagraphStyle("Title", parent=ss["Title"], textColor=NAVY, fontSize=26, + leading=30, spaceAfter=6) +SUB = ParagraphStyle("Sub", parent=ss["Title"], textColor=INDIGO, fontSize=13, + leading=17, spaceAfter=4) +CELL = ParagraphStyle("Cell", parent=BODY, fontSize=8.8, leading=11.5, spaceAfter=0) +CELLH = ParagraphStyle("CellH", parent=CELL, textColor=colors.white, + fontName="Helvetica-Bold") + +story = [] + + +def bullets(items, style=BODY): + return ListFlowable( + [ListItem(Paragraph(t, style), leftIndent=10, value="•") for t in items], + bulletType="bullet", start="•", leftIndent=12, spaceBefore=1, spaceAfter=6, + ) + + +def rule(): + story.append(Spacer(1, 3)) + story.append(HRFlowable(width="100%", thickness=0.6, color=INDIGO)) + story.append(Spacer(1, 5)) + + +# ---------------- Title page ---------------- +story.append(Spacer(1, 40)) +story.append(Paragraph("AutoSampler", TITLE)) +story.append(Paragraph("Development Changelog & Feature Summary", SUB)) +rule() +story.append(Paragraph( + "From an MD coverage sampler to an autonomous, " + "MSM‑convergence‑driven adaptive‑sampling framework.", BODY)) +story.append(Spacer(1, 6)) +story.append(Paragraph( + "This document summarises the new development cycle on the " + "devel branch and highlights every new feature and improvement over " + "the original v2.0.0 baseline. All new behaviour is opt‑in; existing " + "input files keep working unchanged.", BODY)) +story.append(Spacer(1, 10)) + +hi = Table([ + [Paragraph("Baseline", CELLH), Paragraph("New (devel)", CELLH)], + [Paragraph("v2.0.0 — coverage-driven adaptive sampling", CELL), + Paragraph("MSM-convergence-driven, HPC-scalable, VAMP-2-optimised", CELL)], + [Paragraph("Stops on bin-occupancy saturation", CELL), + Paragraph("Stops on real MSM convergence (timescales / VAMP-2 / error)", CELL)], + [Paragraph("Fixed CV / TICA / TVAE / PCA / deep-TICA", CELL), + Paragraph("+ VAMPNet, SPIB, VAMP-2 feature selection & optimisation", CELL)], + [Paragraph("Local multiprocessing only", CELL), + Paragraph("Local + SLURM + PBS array jobs, fault-tolerant", CELL)], + [Paragraph("No tests / CI / docs", CELL), + Paragraph("96 tests, CI, full docs site, notebook, PDF", CELL)], +], colWidths=[78*mm, 92*mm]) +hi.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), NAVY), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LGREY]), + ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#B0BEC5")), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), +])) +story.append(hi) +story.append(Spacer(1, 10)) +story.append(Paragraph( + "Status: 96 automated tests passing; continuous integration green on " + "Python 3.10 & 3.11; ruff-clean. Pull request #1 (devel → main) open.", SMALL)) +story.append(PageBreak()) + +# ---------------- Headline new features ---------------- +story.append(Paragraph("1. Headline new features", H1)) +rule() +story.append(Paragraph("Markov State Model (MSM) convergence engine", H2)) +story.append(Paragraph( + "The central gap in v2.0.0 — there was no MSM at all, and \"convergence\" " + "meant bin-occupancy saturation. The new autosampler/msm/ subsystem " + "(built on deeptime) builds an MSM every iteration and stops sampling when it " + "is genuinely converged.", BODY)) +story.append(bullets([ + "MSMEstimator: clustering (k-means / regular-space) → transition " + "counts → MLE or Bayesian MSM → implied timescales, VAMP-2 score, " + "PCCA+ metastable states, stationary distribution.", + "ConvergenceMonitor: composable, pluggable criteria — implied-timescale " + "stability, VAMP-2 plateau, stationary-distribution drift, Bayesian " + "statistical-error thresholds, and a flux-weighted transition-matrix " + "criterion (analytic Dirichlet Tij " + "uncertainty) — combined with all / any + patience to require both kinetic " + "resolution and statistical convergence of the transition matrix.", + "MSMSpawner (spawn_scheme: msm): " + "uncertainty × leverage × flux microstate seeding " + "(πi·|ψi|·" + "σout,i + α/√ci) that throws runs " + "at the transitions whose in/out rates are uncertain and important; " + "least-counts fallback before the first MSM, with stable clustering for " + "comparable microstate IDs across iterations.", +])) + +story.append(Paragraph("Cutting-edge & optimised collective variables", H2)) +story.append(bullets([ + "New deep CV methods VAMPNet and SPIB (State Predictive " + "Information Bottleneck) added to a single CV registry beside TICA, TVAE, " + "PCA, deep-TICA, and deep-LDA.", + "VAMP-2 feature selection & optimisation: automatically select and " + "adaptively update the input features (and even the feature type) that " + "best resolve the slow dynamics, via a greedy VAMP-2 optimisation protocol.", + "VAMP-2-driven adaptive retraining: retrain the CV only when its score " + "on fresh data degrades, instead of on a blind fixed schedule.", +])) + +story.append(Paragraph("Landscape-adaptive binning", H2)) +story.append(Paragraph( + "The density / weighted-ensemble spawners stratify the CV space into bins. A " + "uniform grid wastes replicas in flat basins and, worse, lets walkers slide " + "back across a wide bin at a barrier before the lag time elapses — stalling " + "the flux. The new autosampler/binning/adaptive.py makes bins " + "landscape-adaptive, recomputed every iteration (selected by " + "binning.scheme; opt-in, default " + "uniform).", BODY)) +story.append(bullets([ + "gradient: equi-resistance edges (∫ exp(βF) " + "∝ ∫ 1/P) place boundaries where the sampled density is low " + "— fine across barriers, coarse in basins.", + "mab: Minimal-Adaptive-Binning-style uniform bins between the occupied " + "extremes plus narrow foothold bins at the moving fronts.", + "eigenvector: bin uniformly along the leading (slowest) CV coordinate " + "only — a committor proxy that is automatically fine at the barrier and folds " + "many CVs into one coordinate.", +])) + +story.append(Paragraph("Scalability: workstation and HPC", H2)) +story.append(bullets([ + "Pluggable execution backends: local (multi-GPU workstation), " + "SLURM, and PBS/Torque — walkers dispatched as scheduler array " + "jobs, one per iteration.", + "Fault tolerant: completion is driven by filesystem result markers and failed " + "walkers are automatically resubmitted.", + "Switching from a laptop to a CPU-only HPC cluster is a one-line config change.", +])) + +story.append(Paragraph("Analysis, tooling, and end-user experience", H2)) +story.append(bullets([ + "Weighted-ensemble resampling: a correct, weight-conserving split/merge " + "implementation (spawn_scheme: we), replacing a " + "non-functional placeholder.", + "MSM analysis & plotting: implied timescales, VAMP-2 / timescale " + "convergence, free-energy surfaces, metastable free energies, and MSM network " + "diagrams, via the autosampler-analyze CLI.", + "One input file for everything: autosampler-init " + "writes a fully-annotated YAML exposing every method, feature, and " + "hyperparameter; documented for end users.", + "Full documentation site, an executed Jupyter notebook tutorial with rendered " + "plots, and example run scripts for local / SLURM / PBS.", +])) +story.append(PageBreak()) + +# ---------------- Detailed sections by area ---------------- +story.append(Paragraph("2. Detailed changes by area", H1)) +rule() + +sections = [ + ("Sampling & convergence", [ + "New MSM subsystem (estimator, diagnostics, convergence monitor).", + "Flux-weighted transition-matrix convergence criterion (analytic Dirichlet " + "error on T_ij) that augments the spectral criteria under mode: all.", + "Uncertainty × leverage × flux MSM spawner (least-counts " + "fallback) and weighted-ensemble spawner.", + "Landscape-adaptive binning (gradient / mab / eigenvector) for the density " + "and weighted-ensemble spawners; opt-in, recomputed each iteration.", + "Convergence now based on MSM kinetics, not just spatial coverage " + "(legacy occupancy criterion retained as one selectable option).", + ]), + ("Collective variables & features", [ + "VAMPNet and SPIB deep CVs; unified CV method registry with availability " + "checks and actionable install hints.", + "VAMP-2 feature scoring, candidate ranking, and greedy column/feature-type " + "optimisation, with adaptive updates during the run.", + "Adaptive CV retraining policy driven by the VAMP-2 score.", + ]), + ("Execution & scalability", [ + "ExecutionBackend abstraction with local / SLURM / PBS implementations.", + "Per-iteration array-job submission, polling, result collection, and " + "automatic resubmission of failed walkers.", + "Configurable scheduler resources (partition/queue, walltime, CPUs/GPUs " + "per task, memory, module loads).", + ]), + ("Robustness, reproducibility & engineering", [ + "Pydantic v2 configuration with strict validation of every option.", + "Checkpoint format versioning; backward-compatible loading of old " + "checkpoints; resume restores MSM, feature-selection, and retraining state.", + "MD subprocess timeouts; trajectory-file validation; portable temp paths; " + "narrower exception handling; removed dead code.", + "Deterministic seeding across NumPy, PyTorch, and Lightning.", + "Test suite (96 tests), GitHub Actions CI, ruff/black/isort, pre-commit, " + "and contribution guidelines.", + ]), + ("Analysis & usability", [ + "autosampler-analyze: one-command multi-panel convergence report.", + "Matplotlib-free analysis data utilities plus plotting helpers.", + "autosampler-init starter input file; full MkDocs documentation; " + "rendered Jupyter notebook tutorial; local/SLURM/PBS example scripts.", + ]), +] +for title, items in sections: + story.append(Paragraph(title, H2)) + story.append(bullets(items)) + +story.append(PageBreak()) + +# ---------------- Capability comparison table ---------------- +story.append(Paragraph("3. Capability comparison vs the original", H1)) +rule() +rows = [ + ["Capability", "Original v2.0.0", "New (devel)"], + ["Convergence", "Bin-occupancy saturation", + "MSM: timescales, VAMP-2, T_ij flux-weighted error"], + ["MSM building", "None", "Full pipeline + Bayesian errors + PCCA+"], + ["CV methods", "fixed, PCA, TICA, TVAE, deep-TICA", + "+ VAMPNet, SPIB, deep-LDA (unified registry)"], + ["Feature choice", "Manual, fixed for the run", + "VAMP-2 selection & optimisation, adaptive"], + ["CV retraining", "Fixed schedule", "Fixed or VAMP-2-adaptive"], + ["Spawning", "density, voronoi, lof, fps", + "+ msm (uncertainty×leverage×flux), we (weighted ensemble)"], + ["Binning", "Uniform grid only", + "+ gradient / mab / eigenvector (landscape-adaptive)"], + ["Execution", "Local multiprocessing", + "Local + SLURM + PBS array jobs, resubmission"], + ["Weighted ensemble", "Placeholder stub (no-op)", + "Correct, weight-conserving split/merge"], + ["Analysis/plots", "None bundled", + "autosampler-analyze: ITS, VAMP-2, FES, network"], + ["Input file", "YAML (sparse examples)", + "autosampler-init annotated template + docs"], + ["Tests / CI", "None", "96 tests, CI (3.10 & 3.11), ruff"], + ["Docs / tutorials", "README only", + "MkDocs site, notebook tutorial, changelog PDF"], +] +data = [[Paragraph(c, CELLH if i == 0 else CELL) for c in r] + for i, r in enumerate(rows)] +tbl = Table(data, colWidths=[30*mm, 62*mm, 78*mm], repeatRows=1) +tbl.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), NAVY), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LGREY]), + ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#B0BEC5")), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5), + ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("TEXTCOLOR", (0, 1), (0, -1), INDIGO), +])) +story.append(tbl) +story.append(Spacer(1, 10)) +story.append(Paragraph( + "Compatibility: every new capability is opt-in. A v2.0.0 input file " + "runs unchanged; advanced blocks (msm, feature_selection, execution, retrain " + "policy) are simply omitted by default.", SMALL)) +story.append(PageBreak()) + +# ---------------- New configuration options ---------------- +story.append(Paragraph("4. New configuration options", H1)) +rule() +story.append(Paragraph( + "Every option below is optional; omitting the block keeps the previous " + "behaviour. See the configuration reference and per-topic docs pages.", BODY)) +opt_rows = [ + ["Block", "Key", "Default", "What it does"], + ["msm", "convergence_criteria: transition_matrix", "—", + "Flux-weighted Dirichlet uncertainty gate on T_ij (params: tol, min_flux)."], + ["msm", "stable_clustering", "false", + "Seed clustering from previous centres so microstate IDs / T_ij stay comparable."], + ["msm", "spawn_uncertainty", "true", + "Weight msm spawning by outflow uncertainty (× leverage × flux)."], + ["msm", "spawn_leverage", "1", + "Number of slow eigenvectors used for the leverage factor."], + ["msm", "spawn_alpha", "1.0", + "Weight of the least-counts exploration term in msm spawning."], + ["binning", "scheme", "uniform", + "uniform | gradient | mab | eigenvector — landscape-adaptive bin placement."], + ["binning", "n_fine", "100", + "Density-histogram resolution for the gradient scheme."], + ["binning", "smoothing", "3", + "Density smoothing window for the gradient scheme."], +] +opt_data = [[Paragraph(c, CELLH if i == 0 else CELL) for c in r] + for i, r in enumerate(opt_rows)] +opt = Table(opt_data, colWidths=[20*mm, 50*mm, 16*mm, 84*mm], repeatRows=1) +opt.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), NAVY), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LGREY]), + ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#B0BEC5")), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTNAME", (1, 1), (1, -1), "Courier"), + ("FONTSIZE", (1, 1), (1, -1), 7.8), + ("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5), + ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("TEXTCOLOR", (0, 1), (0, -1), INDIGO), +])) +story.append(opt) +story.append(Spacer(1, 8)) +story.append(Paragraph( + "Example — drive convergence with the transition-matrix gate and adaptive bins:", + SMALL)) +code = ParagraphStyle("Code", parent=SMALL, fontName="Courier", fontSize=8, + backColor=LGREY, leading=11, leftIndent=6, spaceBefore=2) +for line in [ + "msm:", + "  enabled: true", + "  stable_clustering: true", + "  convergence_mode: all", + "  convergence_criteria:", + "    - {name: vamp2, params: {tol: 0.05}}", + "    - {name: transition_matrix, params: {tol: 0.2, min_flux: 1.0e-4}}", + "binning:", + "  scheme: gradient", +]: + story.append(Paragraph(line, code)) +story.append(Spacer(1, 8)) +story.append(Paragraph( + "Generated for the AutoSampler devel branch. See CHANGELOG.md and the " + "documentation for full details.", SMALL)) + + +def footer(canvas, doc): + canvas.saveState() + canvas.setFont("Helvetica", 7.5) + canvas.setFillColor(DGREY) + canvas.drawString(20*mm, 12*mm, "AutoSampler — Development Changelog") + canvas.drawRightString(190*mm, 12*mm, f"Page {doc.page}") + canvas.restoreState() + + +doc = SimpleDocTemplate( + OUT, pagesize=A4, leftMargin=20*mm, rightMargin=20*mm, + topMargin=18*mm, bottomMargin=18*mm, + title="AutoSampler Development Changelog", + author="AutoSampler", +) +doc.build(story, onFirstPage=footer, onLaterPages=footer) +print("WROTE", OUT)