ynuozhang
Upload ScooBDoob code without binary caches
31d1d63
Raw History Blame Contribute Delete
18.3 kB
from pathlib import Path
import argparse
import csv
import hashlib
import importlib.metadata
import itertools
import json
import platform
import sys
import time
import numpy as np
OUT = Path(__file__).resolve().parent
ROOT = OUT.parents[1]
sys.path.insert(0, str(ROOT))
from scoobdoob import _conditioning
CONDITIONS = [
("doob", None),
("none", 0),
("p10", 10),
("p25", 25),
("p50", 50),
("p75", 75),
]
SETTINGS = dict(
cluster_seeds=list(range(20)),
k=100,
tica_lag=20,
msm_lag=5,
horizon=16,
alpha_per_transition=7.5,
floor_percentiles=[10, 25, 50, 75],
no_floor_control=True,
start_law="uniform on the original source set; fixed-start mixture",
s1_radius=0.25,
)
def dump(path, obj):
path.write_text(json.dumps(obj, indent=2, allow_nan=False) + "\n")
def digest(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def exact_metrics(kernels, mu, region, targets, corridor):
"""Forward full law and killed laws, including the state at step zero."""
m, n = len(mu), len(kernels)
mass = mu.copy()
survival = mu.copy()
survival[region] = 0
avoiding = np.broadcast_to(mu, (m, m)).copy()
np.fill_diagonal(avoiding, 0)
s1_occupancy = float(mass[region].sum())
self_steps = 0.0
max_outside = float(mass[~corridor].sum())
max_outside_arrivals = 0.0
for K in kernels:
self_steps += float(mass @ np.diag(K))
mass = mass @ K
np.testing.assert_allclose(mass.sum(), 1, atol=2e-12, rtol=0)
max_outside = max(max_outside, float(mass[~corridor].sum()))
max_outside_arrivals = max(max_outside_arrivals, float(mass[~corridor].sum()))
s1_occupancy += float(mass[region].sum())
survival = survival @ K
survival[region] = 0
avoiding = avoiding @ K
np.fill_diagonal(avoiding, 0)
hit_each = np.clip(1 - avoiding.sum(axis=1), 0, 1)
return dict(
s1_probability=float(np.clip(1 - survival.sum(), 0, 1)),
expected_distinct_states=float(hit_each.sum()),
expected_self_transitions=self_steps,
self_transition_fraction=self_steps / n,
expected_s1_state_observations=s1_occupancy,
terminal_probability=float(mass[targets].sum()),
max_outside_corridor_probability=max_outside,
max_outside_arrival_mask_probability=max_outside_arrivals,
)
def reference_hit_probability(P, w, mu, region, targets, horizon):
"""Independent ratio of weighted path sums, separately for each start."""
full = np.zeros(len(P))
full[targets] = 1
avoided = full.copy()
avoided[region] = 0
w_avoid = w.copy()
w_avoid[region] = 0
for _ in range(horizon):
full = P @ (w * full)
avoided = P @ (w_avoid * avoided)
scale = float(full.max())
if scale <= 0:
raise RuntimeError("No feasible weighted continuation")
full /= scale
avoided /= scale
initial = mu > 0
if np.any(full[initial] <= 0):
raise RuntimeError("Infeasible prescribed start")
avoided[region] = 0
return float(1 - mu[initial] @ (avoided[initial] / full[initial]))
def validate_toy():
"""Brute-force the weighted reference law, not a second kernel simulation."""
P = np.array([[0.5, 0.4, 0.1], [0.1, 0.6, 0.3], [0.2, 0.3, 0.5]])
w = np.array([0.7, 0.3, 1.2])
mu = np.array([0.3, 0.7, 0.0])
region = np.array([False, True, False])
targets = np.array([2])
horizon = 4
kernels, _ = _conditioning.build(P, w, targets, horizon, starts=[0, 1])
got = exact_metrics(kernels, mu, region, targets, np.ones(3, dtype=bool))
expected = np.zeros(4)
for i in [0, 1]:
norm = 0.0
moments = np.zeros(4)
for rest in itertools.product(range(3), repeat=horizon):
if rest[-1] != 2:
continue
path = (i,) + rest
prob = float(np.prod([P[a, b] * w[b] for a, b in zip(path[:-1], path[1:])]))
norm += prob
moments += prob * np.array(
[
any(region[s] for s in path),
len(set(path)),
sum(a == b for a, b in zip(path[:-1], path[1:])),
sum(region[s] for s in path),
],
dtype=float,
)
expected += mu[i] * moments / norm
actual = [
got[k]
for k in [
"s1_probability",
"expected_distinct_states",
"expected_self_transitions",
"expected_s1_state_observations",
]
]
np.testing.assert_allclose(actual, expected, atol=2e-12, rtol=0)
other = reference_hit_probability(P, w, mu, region, targets, horizon)
np.testing.assert_allclose(other, expected[0], atol=2e-12, rtol=0)
masked_w = np.array([0.0, 0.3, 1.2])
K, _ = _conditioning.build(P, masked_w, targets, 1, starts=[0])
edge = exact_metrics(K, np.array([1.0, 0.0, 0.0]), region, targets, masked_w > 0)
np.testing.assert_allclose(
[
edge["terminal_probability"],
edge["expected_distinct_states"],
edge["max_outside_corridor_probability"],
edge["max_outside_arrival_mask_probability"],
],
[1.0, 2.0, 1.0, 0.0],
atol=2e-12,
rtol=0,
)
return dict(
exhaustive_reference_check=True,
outside_mask_start_check=True,
max_absolute_error=float(np.max(np.abs(np.array(actual) - expected))),
)
def run(seed):
start_time = time.time()
toy = validate_toy()
from scoobdoob.validation import msm_build as mb, tpt
from scoobdoob.validation import mb_ground_truth as gt
from scoobdoob.validation import mb_data as V
files = sorted((ROOT / "data/mb_reactive").glob("traj_*.npy"))
if len(files) != 12:
raise RuntimeError(
"Expected twelve trajectory files in data/mb_reactive; run python -m scoobdoob.validation.mb_data first"
)
trajs = [np.load(p).astype(np.float64) for p in files]
model = mb.build_full_msm(
trajs, trajs, lag=5, tica_lag=20, k=100, n_comp=2, seed=seed, use_tica=True
)
np.testing.assert_array_equal(model.active_symbols, np.arange(100))
A, B, _ = mb.q2_sign_endpoints(model)
starts, targets = V.orient_endpoints(model, A, B)
starts, targets = np.array(starts), np.array(targets)
xy = model.active_xy
agreement, q, _ = tpt.committor_agreement(model, starts.tolist(), targets.tolist())
flux = tpt.reactive_flux(model, starts.tolist(), targets.tolist())
through = np.asarray(flux.gross_flux).sum(axis=1)
corridor = through / through.sum() > 0.5 / model.n_active
corridor[starts] = True
corridor[targets] = True
saddles = gt.saddle_points()
S1 = saddles[np.argmin(saddles[:, 0])]
landmarks = np.vstack([xy[starts].mean(0), xy[targets].mean(0), saddles])
lo, hi = landmarks.min(0) - 0.35, landmarks.max(0) + 0.35
corridor &= np.all((xy >= lo) & (xy <= hi), axis=1)
counts = model.counts[model.active_symbols].astype(float)
rho = np.maximum(counts, 1) / counts.mean()
react = (q > 0.05) & (q < 0.95)
region = np.linalg.norm(xy - S1, axis=1) < 0.25
if not np.any(region) or not np.any(react):
raise RuntimeError("Missing S1 or reference committor region")
mu = np.zeros(model.n_active)
mu[starts] = 1 / len(starts)
saved_match = None
if seed == 0:
with np.load(
ROOT / "data/mb_panels_data/panels_cache.npz", allow_pickle=True
) as z:
saved_match = dict(
max_state_coordinate_difference=float(np.max(np.abs(xy - z["xy"]))),
counts_equal=bool(np.array_equal(counts, z["counts"])),
corridor_equal=bool(np.array_equal(corridor, z["on"])),
)
np.savez_compressed(
OUT / f"model_{seed:02d}.npz",
P=model.P,
pi=model.stationary,
q=q,
xy=xy,
counts=counts,
rho=rho,
corridor=corridor,
source=starts,
target=targets,
S1_region=region,
mu=mu,
)
records = []
for name, pct in CONDITIONS:
floor = (
None
if pct is None
else (0.0 if pct == 0 else float(np.percentile(rho[react], pct)))
)
w = (
np.ones_like(rho)
if name == "doob"
else np.exp(-7.5 * np.maximum(rho, floor))
)
if np.any(w <= 0):
raise FloatingPointError("A positive density weight underflowed")
w[~corridor] = 0
kernels, h = _conditioning.build(model.P, w, targets, 16, starts=starts)
for K in kernels:
if np.any((K > 0) & (model.P == 0)):
raise RuntimeError("New edge introduced")
record = exact_metrics(kernels, mu, region, targets, corridor)
independent = reference_hit_probability(model.P, w, mu, region, targets, 16)
error = abs(independent - record["s1_probability"])
if error > 2e-10:
raise AssertionError(f"Two path-law calculations disagree by {error}")
record.update(
condition=name,
floor_percentile=pct,
density_floor=floor,
fraction_corridor_states_below_floor=(
None if floor is None else float(np.mean(rho[corridor] < floor))
),
independent_recursion_error=error,
)
records.append(record)
print(
f"seed={seed} {name} S1={record['s1_probability']:.8f} "
f"distinct/path={record['expected_distinct_states']:.6f}",
flush=True,
)
source_names = [
"make_mb_panels.py",
"mb_stats.py",
"scoobdoob/validation/msm_build.py",
"scoobdoob/_conditioning.py",
"scoobdoob/validation/mb_data.py",
]
sources = {str(p.relative_to(ROOT)): digest(p) for p in files}
sources.update({p: digest(ROOT / p) for p in source_names})
sources[str(Path(__file__).relative_to(ROOT))] = digest(__file__)
versions = {
p: importlib.metadata.version(p)
for p in ["numpy", "scipy", "scikit-learn", "deeptime"]
}
dump(
OUT / f"result_{seed:02d}.json",
dict(
seed=seed,
settings=SETTINGS,
records=records,
n_frames=sum(len(t) for t in trajs),
n_active=model.n_active,
corridor_states=int(corridor.sum()),
s1_states=int(region.sum()),
source_states_outside_arrival_mask=starts[~corridor[starts]].tolist(),
target_states_outside_arrival_mask=targets[~corridor[targets]].tolist(),
reference_committor_agreement=float(agreement),
seed0_saved_figure_check=saved_match,
validation=toy,
source_sha256=sources,
versions=versions,
python=platform.python_version(),
elapsed_seconds=time.time() - start_time,
),
)
def summarize():
results = [
json.loads((OUT / f"result_{s:02d}.json").read_text()) for s in range(20)
]
if any(r["settings"] != SETTINGS for r in results):
raise RuntimeError("Inconsistent settings")
if len({json.dumps(r["source_sha256"], sort_keys=True) for r in results}) != 1:
raise RuntimeError("Source/data hashes changed across jobs")
records = [
{**row, "cluster_seed": r["seed"]} for r in results for row in r["records"]
]
with (OUT / "all_results.csv").open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(records[0]))
writer.writeheader()
writer.writerows(records)
values = {
name: np.array(
[
next(x for x in r["records"] if x["condition"] == name)[
"s1_probability"
]
for r in results
]
)
* 100
for name, _ in CONDITIONS
}
stats = {}
for name, _ in CONDITIONS:
y = values[name]
delta = y - values["doob"]
delta25 = y - values["p25"]
by = [next(x for x in r["records"] if x["condition"] == name) for r in results]
stats[name] = dict(
s1_mean_pct=float(y.mean()),
s1_sd_pct=float(y.std(ddof=1)),
s1_min_pct=float(y.min()),
s1_max_pct=float(y.max()),
paired_gain_vs_doob_mean_pp=float(delta.mean()),
paired_gain_vs_doob_min_pp=float(delta.min()),
paired_gain_vs_doob_max_pp=float(delta.max()),
paired_vs_p25_mean_pp=float(delta25.mean()),
paired_vs_p25_min_pp=float(delta25.min()),
paired_vs_p25_max_pp=float(delta25.max()),
n_clusterings_better_than_doob=int(np.sum(delta > 1e-10)),
distinct_per_path_mean=float(
np.mean([x["expected_distinct_states"] for x in by])
),
self_steps_mean=float(
np.mean([x["expected_self_transitions"] for x in by])
),
)
archive = json.loads((ROOT / "data/mb_stats.json").read_text())
comparison = {
name: dict(
archived_mean_pct=float(np.mean(archive[key])),
mean_exact_minus_archived_pp=float(np.mean(values[name] - archive[key])),
max_absolute_difference_pp=float(
np.max(np.abs(values[name] - archive[key]))
),
)
for name, key in [("doob", "raw"), ("p25", "fk")]
}
check = dict(
toy_exhaustive_passed=all(
r["validation"]["exhaustive_reference_check"] for r in results
),
max_independent_recursion_error=max(
x["independent_recursion_error"] for x in records
),
max_terminal_error=max(abs(x["terminal_probability"] - 1) for x in records),
max_outside_corridor_probability=max(
x["max_outside_corridor_probability"] for x in records
),
max_outside_arrival_mask_probability=max(
x["max_outside_arrival_mask_probability"] for x in records
),
endpoint_mask_exceptions=[
dict(
seed=r["seed"],
source=r["source_states_outside_arrival_mask"],
target=r["target_states_outside_arrival_mask"],
)
for r in results
if r["source_states_outside_arrival_mask"]
or r["target_states_outside_arrival_mask"]
],
seed0_saved_figure_check=results[0]["seed0_saved_figure_check"],
)
summary = dict(
settings=SETTINGS,
conditions=stats,
checks=check,
archive_comparison=comparison,
scope="Exact probabilities on refitted MSMs from fixed MD data. SD is clustering sensitivity, not MD uncertainty.",
completed_clusterings=len(results),
)
dump(OUT / "summary.json", summary)
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
labels = ["No floor", "10th", "25th (original)", "50th", "75th"]
names = ["none", "p10", "p25", "p50", "p75"]
fig, axes = plt.subplots(1, 2, figsize=(10, 4.3))
x = np.arange(5)
for s in range(20):
axes[0].plot(
x, [values[n][s] for n in names], color="#93b6cc", alpha=0.42, lw=0.8
)
axes[1].plot(
x,
[values[n][s] - values["doob"][s] for n in names],
color="#93b6cc",
alpha=0.42,
lw=0.8,
)
for ax, metric in zip(axes, ["s1_mean_pct", "paired_gain_vs_doob_mean_pp"]):
ax.plot(
x,
[stats[n][metric] for n in names],
"o-",
color="#164e70",
lw=2,
label="Mean of 20 clusterings",
)
ax.set_xticks(x, labels, rotation=18)
ax.set_xlabel("Density-floor percentile")
ax.spines[["top", "right"]].set_visible(False)
ax.grid(axis="y", alpha=0.2)
axes[0].axhline(
stats["doob"]["s1_mean_pct"], ls="--", color="#777777", label="Pure-Doob mean"
)
axes[1].axhline(0, ls="--", color="#777777")
axes[0].set_ylabel("Exact probability of S1 visitation (%)")
axes[1].set_ylabel("Paired gain over pure Doob (percentage points)")
axes[0].legend(frameon=False, fontsize=8)
fig.suptitle("MB floor sensitivity at fixed density strength and horizon")
fig.tight_layout()
(ROOT / "figures").mkdir(parents=True, exist_ok=True)
for ext in ["pdf", "png", "svg"]:
fig.savefig(
ROOT / "figures" / f"floor_sensitivity.{ext}", dpi=180, bbox_inches="tight"
)
lines = [
"MB FLOOR SENSITIVITY — completed",
"",
summary["scope"],
"",
"Condition S1 mean +/- SD (%) gain vs Doob (pp) better /20",
]
for name, _ in CONDITIONS:
z = stats[name]
lines.append(
f"{name:12s} {z['s1_mean_pct']:7.3f} +/- {z['s1_sd_pct']:6.3f} "
f" {z['paired_gain_vs_doob_mean_pp']:+7.3f} "
f"{z['n_clusterings_better_than_doob']}"
)
lines += [
"",
"Checks:",
json.dumps(check, indent=2),
"",
"The original 25th-percentile setting is retained. No optimum was selected.",
"Use per-clustering paired differences in all_results.csv; original results were not overwritten.",
"Exact evaluation has no rollout Monte Carlo error. Refitting may differ from archived finite samples.",
"Aib9 and the p53 held-out density experiment used no density floor; this test concerns MB.",
]
(OUT / "RESULTS.txt").write_text("\n".join(lines) + "\n")
print("\n".join(lines), flush=True)
if __name__ == "__main__":
p = argparse.ArgumentParser()
group = p.add_mutually_exclusive_group(required=True)
group.add_argument("--seed", type=int, choices=range(20))
group.add_argument("--summarize", action="store_true")
group.add_argument("--validate", action="store_true")
args = p.parse_args()
if args.validate:
print(json.dumps(validate_toy(), indent=2))
elif args.summarize:
summarize()
else:
run(args.seed)