ScooBDoob / validate_aib9.py
ynuozhang
Upload ScooBDoob code without binary caches
31d1d63
Raw History Blame Contribute Delete
11.6 kB
#!/usr/bin/env python
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
import matplotlib
matplotlib.use("Agg")
from scoobdoob.validation import msm_build as mb
from scoobdoob.validation import tpt, bridge, aib9
ROOT = Path(__file__).resolve().parent
DEFAULT_DATA = aib9.OPENMM_DIR
OUTDIR = ROOT / "data" / "validation_aib9"
LAG = 5
K = 100
N_COMP = 4
HORIZON = 60
TOL_Q = 0.10
def gate_msm(feat, hand, temps):
print("\n" + "=" * 70)
print("PART 2.0/2.1 Build Aib9 MSM (36D sin/cos -> TICA -> connected -> rev MLE)")
print("=" * 70)
model = mb.build_full_msm(
feat, hand, lag=LAG, k=K, n_comp=N_COMP, seed=0, use_tica=True
)
n_off = K - len(model.active_symbols)
lam1 = float(np.real(model.model.eigenvalues(k=1)[0]))
print(
f" microstates K={K}, connected set={model.n_active} "
f"({n_off} dropped), lambda_1={lam1:.5f}"
)
lags = [2, 5, 10, 20, 40]
lg, its = mb.implied_timescales(model.dtrajs, lags, k=K, n_its=3)
ck = mb.ck_test(model.dtrajs, lag=LAG, k=K, factors=(2, 3))
print(
f" ITS(tau*={LAG}, frames) slowest 3 = "
f"{np.array2string(its[lags.index(LAG)], precision=1)}"
)
print(f" CK error: 2tau={ck[2]:.3f}, 3tau={ck[3]:.3f}")
return model, {
"n_active": int(model.n_active),
"n_dropped": int(n_off),
"lambda1": lam1,
"ck": {int(k): v for k, v in ck.items()},
}
def gate_endpoints(model):
print("\n" + "=" * 70)
print("GATE 2.2 Endpoints R / L from helical handedness")
print("=" * 70)
labels = aib9.classify_microstates(model.active_xy)
sets = {
k: np.where(labels == k)[0].tolist() for k in ["R", "L", "I_N", "I_C", "other"]
}
print(
f" microstate labels: R={len(sets['R'])} L={len(sets['L'])} "
f"I_N={len(sets['I_N'])} I_C={len(sets['I_C'])} other={len(sets['other'])}"
)
pL_R = model.active_xy[sets["R"]][:, aib9.CORE].mean()
pL_L = model.active_xy[sets["L"]][:, aib9.CORE].mean()
print(
f" R: core P(left)={pL_R:.2f} (right helix); "
f"L: core P(left)={pL_L:.2f} (left helix)"
)
A2, B2, _ = mb.q2_sign_endpoints(model, k_each=6)
a2_isR = np.mean([labels[i] in ("R",) for i in A2])
b2_isL = np.mean([labels[i] in ("L",) for i in B2])
print(
f" q2-sign split agreement with R/L handedness: "
f"{max(a2_isR, 1 - a2_isR) * 100:.0f}% / {max(b2_isL, 1 - b2_isL) * 100:.0f}%"
)
return labels, sets
def gate_committor(model, sets, labels):
print("\n" + "=" * 70)
print("GATE 2.3 Committor-1/2 vs KNOWN intermediates I_N, I_C (THE GATE)")
print("=" * 70)
R, L = sets["R"], sets["L"]
agree, q, _ = tpt.committor_agreement(model, R, L)
print(f" committor solver cross-check (deeptime vs linsolve): max|dq|={agree:.2e}")
print(f" q on R ~ {q[R].mean():.3f}, q on L ~ {q[L].mean():.3f}")
half = tpt.committor_half_states(q, tol=TOL_Q)
inter = set(sets["I_N"]) | set(sets["I_C"])
half_in_inter = [h for h in half if h in inter]
frac_states = len(half_in_inter) / max(len(half), 1)
pi = model.stationary
mass_frac = (
pi[half_in_inter].sum() / max(pi[list(half)].sum(), 1e-12) if len(half) else 0.0
)
in_basin = [h for h in half if labels[h] in ("R", "L")]
qIN = q[sets["I_N"]].mean() if sets["I_N"] else np.nan
qIC = q[sets["I_C"]].mean() if sets["I_C"] else np.nan
n_half_IN = sum(h in set(sets["I_N"]) for h in half)
n_half_IC = sum(h in set(sets["I_C"]) for h in half)
print(
f" committor-1/2 ensemble: {len(half)} states "
f"({n_half_IN} in I_N, {n_half_IC} in I_C)"
)
print(
f" overlap with intermediates: {frac_states * 100:.0f}% of states, "
f"{mass_frac * 100:.0f}% of committor-1/2 mass"
)
print(f" committor-1/2 states inside an endpoint (R/L) basin: {len(in_basin)}")
sym = "symmetric" if abs(qIN - qIC) < 0.1 else "asymmetric"
print(
f" mean committor of I_N={qIN:.2f}, I_C={qIC:.2f} "
f"-> the two channels are {sym}. Aib is achiral, so a symmetric split "
f"is the physically expected result."
)
passed = (agree < 1e-6) and (mass_frac >= 0.5) and (len(in_basin) == 0)
print(
f" -> {'PASS' if passed else 'FAIL'} "
f"(committor-1/2 coincides with the known intermediates)"
)
return (
passed,
{
"committor_agreement": agree,
"n_half": int(len(half)),
"overlap_frac_states": frac_states,
"overlap_frac_mass": float(mass_frac),
"n_half_in_basin": len(in_basin),
"q_IN": float(qIN),
"q_IC": float(qIC),
"pass": bool(passed),
},
q,
half,
)
def gate_flux(model, sets, q, half):
print("\n" + "=" * 70)
print("GATE 2.4 Reactive flux + bridge rollouts route through intermediates")
print("=" * 70)
R, L = sets["R"], sets["L"]
IN, IC = set(sets["I_N"]), set(sets["I_C"])
rf = tpt.reactive_flux(model, R, L)
order = ["R", "I_N", "I_C", "other", "L"]
_, cg = rf.coarse_grain([sets[k] for k in order])
F = np.asarray(cg.net_flux)
tot = float(cg.total_flux)
f_IN = float(F[0, 1] / tot)
f_IC = float(F[0, 2] / tot)
f_other = float(F[0, 3] / tot)
f_direct = float(F[0, 4] / tot)
f_thru = f_IN + f_IC + f_other
print(f" total reactive flux = {tot:.3e}")
print(
f" flux routed through resolved intermediates: {f_thru * 100:.0f}% "
f"(direct R->L: {f_direct * 100:.0f}% -- small once swap artifacts removed)"
)
print(
f" TWO CHANNELS: flip-from-N (I_N) {f_IN * 100:.0f}% | "
f"flip-from-C (I_C) {f_IC * 100:.0f}% | middle/other {f_other * 100:.0f}%"
)
band = set(np.where((q > 0.2) & (q < 0.8))[0].tolist())
Ph = bridge.doob_kernels(model.P, L, HORIZON)
paths = bridge.sample_bridge_paths(Ph, R, n_paths=300, seed=0)
succ = bridge.success_with_ci(paths, L, seed=0)
visit_band = np.mean([bool(set(map(int, p)) & band) for p in paths])
visit_IN = np.mean([bool(set(map(int, p)) & IN) for p in paths])
visit_IC = np.mean([bool(set(map(int, p)) & IC) for p in paths])
print(
f" bridge rollouts R->L: success {succ['rate'] * 100:.0f}% "
f"CI[{succ['ci'][0] * 100:.0f},{succ['ci'][1] * 100:.0f}], "
f"cross committor band {visit_band * 100:.0f}%"
)
print(
f" rollouts touching I_N {visit_IN * 100:.0f}%, I_C {visit_IC * 100:.0f}% "
f"(both channels sampled)"
)
passed = (f_IN > 0 and f_IC > 0) and (succ["rate"] > 0.9)
print(
f" -> {'PASS' if passed else 'FAIL'} "
f"(multi-channel reactive ensemble through both known intermediates)"
)
return (
passed,
{
"total_flux": tot,
"flux_through_resolved_inter": f_thru,
"flux_direct": f_direct,
"flux_IN": f_IN,
"flux_IC": f_IC,
"flux_other": f_other,
"rollout_success": succ["rate"],
"rollout_cross_band": float(visit_band),
"rollout_visit_IN": float(visit_IN),
"rollout_visit_IC": float(visit_IC),
"pass": bool(passed),
},
paths,
)
def run_robustness(feat, hand):
"""Opt-in K x lag + ITS-convergence robustness report (gate stability)."""
from scoobdoob.validation import robustness as rob
print("\n" + "=" * 70)
print("ROBUSTNESS Are the conclusions stable to K and lag?")
print("=" * 70)
Ks, lags = [75, 100, 150, 200], [2, 5, 10, 20]
results = rob.sweep(feat, hand, Ks, lags, n_comp=N_COMP)
print(
f" {'K':>4} {'lag':>4} {'active':>6} {'lam1':>5} {'CK2t':>5} "
f"{'chalf%':>7} {'direct%':>7} {'I_N%':>5} {'I_C%':>5}"
)
for r in results:
if r.get("ok"):
print(
f" {r['K']:4d} {r['lag']:4d} {r['n_active']:6d} {r['lam1']:5.2f} "
f"{r['ck2']:5.2f} {r['committor_half_mass'] * 100:6.0f}% "
f"{r['direct'] * 100:6.0f}% {r['flux_IN'] * 100:5.0f} {r['flux_IC'] * 100:5.0f}"
)
summ = rob.summarize(results)
print(
f"\n committor-1/2 overlap across all {summ['n_points']} (K,lag): "
f"{summ['committor_half_mass_min'] * 100:.0f}-{summ['committor_half_mass_max'] * 100:.0f}%"
)
print(
f" spurious direct flux across all (K,lag): "
f"{summ['direct_min'] * 100:.0f}-{summ['direct_max'] * 100:.0f}%"
)
print(
f" both channels carry flux at every (K,lag): {summ['both_channels_always']}"
)
its_lags = [1, 2, 3, 5, 8, 12, 20, 30]
lg, its = rob.its_convergence(feat, hand, K=K, lags=its_lags, n_comp=N_COMP)
OUTDIR.mkdir(parents=True, exist_ok=True)
rob.make_figure(
results,
lg,
its,
K_default=K,
lag_default=LAG,
dt_ps=1.0,
outpath=ROOT / "figures" / "validation_aib9" / "aib9_robustness.png",
)
print(f" robustness figure -> {OUTDIR / 'aib9_robustness.png'}")
with open(OUTDIR / "robustness.json", "w") as f:
json.dump(
{
"grid": results,
"summary": summ,
"its_lags": [int(x) for x in lg],
"its": np.where(np.isfinite(its), its, None).tolist(),
},
f,
indent=2,
default=float,
)
print(f" robustness report -> {OUTDIR / 'robustness.json'}")
return summ
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--data", default=DEFAULT_DATA)
ap.add_argument(
"--robustness",
action="store_true",
help="also run the K x lag + ITS-convergence stability report",
)
ap.add_argument("--colvar_stride", type=int, default=5)
args = ap.parse_args()
trajs, temps = aib9.load_openmm_colvar(args.data, stride=args.colvar_stride)
print(f"loaded {sum(len(t) for t in trajs)} frames")
feat = aib9.featurize_sincos(trajs)
hand = aib9.handedness_indicator(trajs)
model, qc = gate_msm(feat, hand, temps)
labels, sets = gate_endpoints(model)
p23, r23, q, half = gate_committor(model, sets, labels)
p24, r24, paths = gate_flux(model, sets, q, half)
rob_summary = run_robustness(feat, hand) if args.robustness else None
print("\n" + "=" * 70)
print("SUMMARY")
print("=" * 70)
for name, ok in [
("2.3 committor-1/2 <-> known intermediates", p23),
("2.4 reactive flux / rollouts through intermediates", p24),
]:
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
gate2 = p23
print(
f"\n GATE 2 (2.3) -> {'PASS' if gate2 else 'FAIL'} "
f"(structural-overlap criterion {'met' if gate2 else 'not met'})"
)
OUTDIR.mkdir(parents=True, exist_ok=True)
report = {
"config": {
"lag": LAG,
"K": K,
"n_comp": N_COMP,
"horizon": HORIZON,
"tol_q": TOL_Q,
"source": "openmm",
},
"qc": qc,
"gate_2_3": r23,
"gate_2_4": r24,
"gate_2_pass": bool(gate2),
"robustness": rob_summary,
}
with open(OUTDIR / "report.json", "w") as f:
json.dump(report, f, indent=2, default=float)
print(f" report -> {OUTDIR / 'report.json'}")
return 0 if gate2 else 1
if __name__ == "__main__":
raise SystemExit(main())