ScooBDoob / make_mb_panels.py
ynuozhang
Upload ScooBDoob code without binary caches
31d1d63
Raw History Blame Contribute Delete
11.7 kB
#!/usr/bin/env python
from __future__ import annotations
import json, os
import numpy as np, torch
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
from scoobdoob.mb_potential import mb_potential_xy
from scoobdoob.validation import msm_build as mb, tpt, bridge
from scoobdoob.validation import mb_ground_truth as gt
from scoobdoob.validation import mb_data as V
plt.rcParams["svg.fonttype"] = "none"
plt.rcParams["pdf.fonttype"] = 42
DATA_DIR = "data/mb_panels_data"
LAG_OPT, N_OPT = 5, 16
TICA_LAG = 20
def channel(paths, q, xy, on, thru, start, end, nbins=18):
"""Dominant route = the maximum-bottleneck (widest) path through the ensemble's OWN empirical transition graph, from the start set to the target set."""
import heapq
from collections import defaultdict
W = defaultdict(float)
for p in paths:
for a, b in zip(p[:-1], p[1:]):
if a != b and on[a] and on[b]:
W[(int(a), int(b))] += 1.0
if not W:
return np.zeros((0, 2))
adj = defaultdict(list)
for (a, b), c in W.items():
adj[a].append((b, c))
S, T = set(map(int, start)), set(map(int, end))
best = defaultdict(float)
prev = {}
heap = []
for a in S:
best[a] = float("inf")
heapq.heappush(heap, (-best[a], a))
seen = set()
while heap:
nb, u = heapq.heappop(heap)
nb = -nb
if u in seen:
continue
seen.add(u)
if u in T:
path = [u]
while path[-1] not in S:
path.append(prev[path[-1]])
return xy[path[::-1]]
for v, c in adj[u]:
w = min(nb, c)
if w > best[v]:
best[v] = w
prev[v] = u
heapq.heappush(heap, (-w, v))
return np.zeros((0, 2))
_CACHE_KEYS = [
"gx",
"gy",
"gv",
"S1",
"S2",
"Axy",
"Bxy",
"xlo",
"xhi",
"ylo",
"yhi",
"cx",
"cy",
"q",
"keep",
"xy",
"mep",
"arc",
"Emep",
"ch_raw",
"ch_fk",
"praw",
"pfk",
"s1_raw",
"s1_fk",
"counts",
"thru",
"on",
]
CACHE_NPZ = "data/mb_panels_data/panels_cache.npz"
def save_cache(D, path=CACHE_NPZ):
os.makedirs(os.path.dirname(path), exist_ok=True)
out = {}
for k, v in D.items():
if k in ("praw", "pfk"):
out[k] = np.array([np.asarray(p) for p in v], dtype=object)
else:
out[k] = np.asarray(v)
np.savez_compressed(path, **out)
print(f"[cache] -> {path}")
def load_cache(path=CACHE_NPZ):
if not os.path.exists(path):
raise SystemExit(f"missing {path}\nrun: python make_mb_panels.py --cache-only")
z = np.load(path, allow_pickle=True)
D = {k: z[k] for k in z.files}
for k in ("praw", "pfk"):
D[k] = list(D[k])
for k in ("xlo", "xhi", "ylo", "yhi", "s1_raw", "s1_fk"):
D[k] = float(D[k])
print(f"[cache] loaded {path}")
return D
def compute():
"""Everything numeric. Returns the dict the plot stage needs; touches no matplotlib."""
trajs = V.load_trajs(str(V.DEFAULT_DATA))
model = mb.build_full_msm(
trajs,
trajs,
lag=LAG_OPT,
tica_lag=TICA_LAG,
k=V.K,
n_comp=2,
seed=0,
use_tica=True,
)
A, B, _ = mb.q2_sign_endpoints(model)
start, end = V.orient_endpoints(model, A, B)
xy = model.state_xy
_, q, _ = tpt.committor_agreement(model, start, end)
rf = tpt.reactive_flux(model, start, end)
thru = np.asarray(rf.gross_flux).sum(1)
sad = gt.saddle_points()
S1 = sad[np.argmin(sad[:, 0])]
S2 = sad[np.argmax(sad[:, 0])]
q1 = q[np.argmin(np.linalg.norm(xy - S1, axis=1))]
q2 = q[np.argmin(np.linalg.norm(xy - S2, axis=1))]
share = thru / thru.sum()
on = share > 0.5 / model.n_active
on[list(start)] = True
on[list(end)] = True
Axy = model.active_to_xy(start).mean(0)
Bxy = model.active_to_xy(end).mean(0)
land = np.vstack([Axy, Bxy, sad])
lo = land.min(0) - 0.35
hi = land.max(0) + 0.35
inbox = (
(xy[:, 0] >= lo[0])
& (xy[:, 0] <= hi[0])
& (xy[:, 1] >= lo[1])
& (xy[:, 1] <= hi[1])
)
on = on & inbox
keep = np.where(on)[0]
cx, cy = xy[keep, 0], xy[keep, 1]
labels = np.concatenate(model.dtrajs)
counts = np.bincount(labels, minlength=model.n_active)[: model.n_active].astype(
float
)
rho = np.maximum(counts, 1) / counts.mean()
react = (q > 0.05) & (q < 0.95)
rho_floor = np.percentile(rho[react], 25)
w_fk = np.exp(-LAG_OPT * 1.5 * np.maximum(rho, rho_floor))
w_fk[~on] = 0.0
w_raw = np.ones_like(w_fk)
w_raw[~on] = 0.0
def rollout(w):
Ph = bridge.doob_kernels(model.P, end, N_OPT, w=w)
return bridge.sample_bridge_paths(Ph, start, n_paths=3000, seed=0)
praw = rollout(w_raw)
pfk = rollout(w_fk)
ch_raw = channel(praw, q, xy, on, thru, start, end)
ch_fk = channel(pfk, q, xy, on, thru, start, end)
n1 = np.linalg.norm(xy - S1, axis=1) < 0.25
s1_raw = np.mean([any(n1[s] for s in p) for p in praw]) * 100
s1_fk = np.mean([any(n1[s] for s in p) for p in pfk]) * 100
M = np.load("data/mb_mep.npz")
mep = M["mep"]
Emep = M["Emep"]
arc = M["arc"]
_keep_mep = mep[:, 1] >= -0.05
mep = mep[_keep_mep]
xlo, xhi = cx.min() - 0.12, cx.max() + 0.12
ylo, yhi = cy.min() - 0.12, cy.max() + 0.12
gx, gy = np.meshgrid(np.linspace(xlo, xhi, 300), np.linspace(ylo, yhi, 300))
with torch.no_grad():
gv = np.clip(
mb_potential_xy(torch.tensor(gx), torch.tensor(gy)).numpy(), -150, 100
)
return {k: v for k, v in locals().items() if k in _CACHE_KEYS}
def render(D):
os.makedirs("figures", exist_ok=True)
"""Plotting only. Reads the cache dict; recomputes nothing. Safe to re-run while restyling."""
gx = D["gx"]
gy = D["gy"]
gv = D["gv"]
S1 = D["S1"]
S2 = D["S2"]
Axy = D["Axy"]
Bxy = D["Bxy"]
xlo = D["xlo"]
xhi = D["xhi"]
ylo = D["ylo"]
yhi = D["yhi"]
cx = D["cx"]
cy = D["cy"]
q = D["q"]
keep = D["keep"]
xy = D["xy"]
mep = D["mep"]
arc = D["arc"]
Emep = D["Emep"]
ch_raw = D["ch_raw"]
ch_fk = D["ch_fk"]
praw = D["praw"]
pfk = D["pfk"]
s1_raw = D["s1_raw"]
s1_fk = D["s1_fk"]
counts = D["counts"]
thru = D["thru"]
on = D["on"]
fig = plt.figure(figsize=(15, 5.4))
gs = GridSpec(1, 4, width_ratios=[1, 1, 1, 0.9], wspace=0.28)
def base(ax):
ax.contour(gx, gy, gv, levels=22, colors="0.82", linewidths=0.5, zorder=0)
ax.scatter(*S1, marker="X", s=150, color="black", zorder=6)
ax.scatter(*S2, marker="X", s=150, color="0.5", zorder=6)
ax.scatter(*Axy, marker="*", s=260, color="#228833", ec="white", zorder=7)
ax.scatter(*Bxy, marker="*", s=260, color="#ee6677", ec="white", zorder=7)
ax.set_xlim(xlo, xhi)
ax.set_ylim(ylo, yhi)
ax.set_xlabel("x")
ax = fig.add_subplot(gs[0])
base(ax)
sc = ax.scatter(cx, cy, c=q[keep], cmap="coolwarm", s=22, zorder=3, vmin=0, vmax=1)
ax.plot(
mep[:, 0],
mep[:, 1],
"--",
color="0.25",
lw=1.6,
zorder=4,
label="min-energy path",
)
ax.set_ylabel("y")
ax.set_title("a MSM microstates + committor", fontsize=10.5, loc="left")
ax.legend(fontsize=7.5, loc="lower right", frameon=False)
ax.annotate(
"S1", S1, xytext=(S1[0] - 0.13, S1[1] + 0.10), fontsize=8.5, weight="bold"
)
ax.annotate(
"S2",
S2,
xytext=(S2[0] + 0.06, S2[1] + 0.10),
fontsize=8.5,
color="0.4",
weight="bold",
)
cax = ax.inset_axes([0.60, 0.90, 0.36, 0.045])
cb = fig.colorbar(sc, cax=cax, orientation="horizontal")
cb.set_label("committor $q^+$", fontsize=7.5)
cb.ax.tick_params(labelsize=6.5)
ax = fig.add_subplot(gs[1])
base(ax)
ax.scatter(cx, cy, s=9, color="0.72", zorder=1)
for p in praw[:150]:
pp = xy[list(p)]
ax.plot(pp[:, 0], pp[:, 1], "-", color="#4477aa", lw=0.5, alpha=0.06, zorder=2)
ax.plot(mep[:, 0], mep[:, 1], "--", color="0.35", lw=1.3, zorder=4)
ax.plot(
ch_raw[:, 0],
ch_raw[:, 1],
"-o",
color="#22447a",
lw=2.6,
ms=4,
zorder=5,
label="dominant route (max-flux)",
)
ax.set_title(f"b Without FK ({s1_raw:.0f}% via S1)", fontsize=10.5, loc="left")
ax.legend(fontsize=7.5, loc="lower right", frameon=False)
ax = fig.add_subplot(gs[2])
base(ax)
ax.scatter(cx, cy, s=9, color="0.72", zorder=1)
for p in pfk[:150]:
pp = xy[list(p)]
ax.plot(pp[:, 0], pp[:, 1], "-", color="#cc6677", lw=0.5, alpha=0.06, zorder=2)
ax.plot(mep[:, 0], mep[:, 1], "--", color="0.35", lw=1.3, zorder=4)
ax.plot(
ch_fk[:, 0],
ch_fk[:, 1],
"-o",
color="#a03050",
lw=2.6,
ms=4,
zorder=5,
label="dominant route (max-flux)",
)
ax.set_title(f"c With floored FK ({s1_fk:.0f}% via S1)", fontsize=10.5, loc="left")
ax.legend(fontsize=7.5, loc="lower right", frameon=False)
ax = fig.add_subplot(gs[3])
ax.plot(arc, Emep, "-", color="0.2", lw=2)
ipk = np.argmax(Emep)
ax.scatter([arc[ipk]], [Emep[ipk]], s=90, color="black", zorder=5)
ax.annotate(
"S1 (rate-limiting)\nbarrier 106 = 6.4 $k_BT$",
(arc[ipk], Emep[ipk]),
xytext=(arc[ipk] - 0.05, Emep[ipk] - 30),
fontsize=8,
ha="center",
arrowprops=dict(arrowstyle="->"),
)
iS2 = np.argmin(np.linalg.norm(mep - S2, axis=1))
ax.scatter([arc[iS2]], [Emep[iS2]], s=70, color="0.5", zorder=5)
ax.annotate(
"S2\nbarrier 9 = 0.5 $k_BT$",
(arc[iS2], Emep[iS2]),
xytext=(arc[iS2] - 0.05, Emep[iS2] - 34),
fontsize=8,
color="0.4",
ha="center",
arrowprops=dict(arrowstyle="->", color="0.4"),
)
ax.set_xlabel("path length A$\\to$B")
ax.set_ylabel("potential energy (MB units)")
ax.set_title("d barrier heights along the MEP", fontsize=10.5, loc="left")
ax.spines[["top", "right"]].set_visible(False)
fig.suptitle(
"The transition is obligated through S1 (d); the raw MSM under-samples it (b), "
"floored FK recovers it (c). $\\tau$=5, N=16, $\\alpha$=1.5, TICA lag 20",
fontsize=11.5,
y=1.0,
)
fig.tight_layout(rect=[0, 0, 1, 0.95])
fig.savefig("figures/mb_panels.svg", bbox_inches="tight")
fig.savefig("figures/mb_panels.png", dpi=200, bbox_inches="tight")
fig.savefig("figures/mb_panels.pdf", bbox_inches="tight")
print(
f"[stats] S1 via raw {s1_raw:.0f}% -> FK {s1_fk:.0f}%; MEP peak at S1 (+106 vs S2 +9)"
)
print("[done] -> figures/mb_panels.svg / .png / .pdf")
def main():
import argparse
ap = argparse.ArgumentParser(description="Muller-Brown panel figure.")
ap.add_argument(
"--cache-only", action="store_true", help="compute and cache, do not plot"
)
ap.add_argument(
"--from-cache",
action="store_true",
help="plot from the cache without recomputing (fast; for restyling)",
)
ap.add_argument("--cache", default=CACHE_NPZ)
a = ap.parse_args()
if a.from_cache:
render(load_cache(a.cache))
return
D = compute()
save_cache(D, a.cache)
if a.cache_only:
return
render(D)
if __name__ == "__main__":
main()