latent_backtrack / scripts /plot_diag_L15.py
Avra98's picture
Add training code (same as GitHub reasoning-by-superposition-latent)
8f46582 verified
Raw
History Blame Contribute Delete
12.7 kB
#!/usr/bin/env python3
"""Live L15 diagnostic plots from training logs.
Parses `logs/diag_L15_*.log` and writes/overwrites figures under `figs/diag_L15/`:
A_train_loss.png train CE loss vs epoch (per arm)
B_stage_timeline.png scheduled stage vs epoch + promote markers
C_frontier_heatmap.png per-hop frontier accuracy over eval epochs
D_ce_score_heatmap.png per-hop ce_score (backtracking metric) over evals
E_time_to_threshold.png epochs-to-first-hit of frontier>=thr per hop
F_latest_bars.png latest per-hop frontier vs ce_score
Also writes `figs/diag_L15/summary.json` with time-to-threshold tables.
Usage:
PYTHONPATH=. python scripts/plot_diag_L15.py
PYTHONPATH=. python scripts/plot_diag_L15.py --watch 120 # replot every 120s
"""
from __future__ import annotations
import argparse
import ast
import json
import re
import time
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
LOG_DIR = ROOT / "logs"
OUT_DIR = ROOT / "figs" / "diag_L15"
# Labels encode BOTH gates: promX = stage advance, btY = backtrack check.
ARMS = [
("promF.95 / btCE.50", "diag_L15_promF095_btCE050", "#1b7837"),
("promF.95 / btCE.90", "diag_L15_promF095_btCE090", "#00441b"),
("promF.95 / btCE.95", "diag_L15_promF095_btCE095", "#006d2c"),
("promF.99 / btCE.90", "diag_L15_promF099_btCE090", "#762a83"),
("promF.99 / btCE.50", "diag_L15_promF099_btCE050", "#c51b7d"),
("promF.95 / btF.95", "diag_L15_promF095_btF095", "#1f78b4"),
("promCE.90 / btCE.90", "diag_L15_promCE090_btCE090", "#d95f02"),
("promF.95 / btNONE", "diag_L15_promF095_btNONE", "#a6611a"),
]
def parse_log(path: Path) -> dict:
if not path.exists():
return {}
# stream large logs; keep last 12MB for speed (full history still usually fits)
with open(path, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(0 if size < 12_000_000 else size - 12_000_000)
text = f.read().decode("utf-8", errors="ignore")
# If we truncated mid-file, also pull promote history from a head+grep via
# a second full regex on a larger window when small enough.
if size < 40_000_000:
text = path.read_text(errors="ignore")
train = [
(int(e), int(s), float(l))
for e, s, l in re.findall(
r"train epoch (\d+)/\d+ stage=(\d+) loss=([0-9.]+)", text
)
]
promotes = [
(int(a), int(b), int(ep))
for a, b, ep in re.findall(
r"PROMOTE stage (\d+) -> (\d+) \|.*? in (\d+) epochs", text
)
]
backtracks = [
(m, None if t == "None" else int(t), float(acc))
for m, t, acc in re.findall(
r"backtrack\[([^\]>=]+)[^]]*\]: target_stage=(\S+) min_mastered[_a-z]*=([0-9.]+)",
text,
)
]
evals = []
for ep_m, metric, raw in re.findall(
r"train epoch (\d+)/\d+.*?\n.*?eval per-hop \(staging_metric=(\w+)\): (\{.*?\})",
text,
flags=re.S,
):
# The above may be too greedy; fall back below.
pass
# Robust: pair nearest preceding train epoch with each eval line
lines = text.splitlines()
cur_ep, cur_stage = None, None
for line in lines:
m = re.match(r"train epoch (\d+)/\d+ stage=(\d+) loss=([0-9.]+)", line)
if m:
cur_ep, cur_stage = int(m.group(1)), int(m.group(2))
continue
# Compact format:
# eval (prom=frontier@0.95 bt=ce_score@0.5) frontier=[1:1.00 2:0.98 ...] ce_score=[1:0.95 ...]
m = re.match(
r"eval \(prom=(\w+)@([0-9.]+) bt=(\w+)@([0-9.]+)\) "
r"frontier=\[([^\]]*)\]\s+ce_score=\[([^\]]*)\]",
line,
)
if m and cur_ep is not None:
def _parse_pairs(s):
out = {}
for tok in s.split():
if ":" not in tok:
continue
h, v = tok.split(":", 1)
try:
out[int(h)] = float(v)
except ValueError:
pass
return out
fr = _parse_pairs(m.group(5))
ce = _parse_pairs(m.group(6))
hops = {
h: {
"frontier": fr.get(h, float("nan")),
"ce_score": ce.get(h, float("nan")),
"reachable": float("nan"),
"optimal": float("nan"),
"superposition": float("nan"),
}
for h in sorted(set(fr) | set(ce))
}
evals.append(
{
"epoch": cur_ep,
"stage": cur_stage,
"metric": m.group(1),
"hops": hops,
}
)
continue
# Legacy full-dict format
m = re.match(
r"eval per-hop \((?:staging_metric=(\w+)|promote=(\w+)@([0-9.]+), "
r"backtrack=(\w+)@([0-9.]+))\): (\{.*\})",
line,
)
if m and cur_ep is not None:
try:
d = ast.literal_eval(m.group(6))
except Exception:
continue
metric = m.group(1) or m.group(2) or "?"
evals.append(
{
"epoch": cur_ep,
"stage": cur_stage,
"metric": metric,
"hops": d,
}
)
return {
"train": train,
"promotes": promotes,
"backtracks": backtracks,
"evals": evals,
}
def time_to_threshold(evals, key: str, thr: float, max_hop: int = 15):
"""First eval epoch where hop h's `key` >= thr, for each hop."""
first = {}
for ev in evals:
for h, c in ev["hops"].items():
if h in first:
continue
if c.get(key, 0) >= thr:
first[h] = ev["epoch"]
return {h: first.get(h) for h in range(1, max_hop + 1)}
def plot_all():
OUT_DIR.mkdir(parents=True, exist_ok=True)
parsed = {}
for label, name, color in ARMS:
p = parse_log(LOG_DIR / f"{name}.log")
if p.get("train") or p.get("evals"):
parsed[name] = {**p, "label": label, "color": color}
if not parsed:
print("no diag logs yet")
return
# A: train loss
fig, ax = plt.subplots(figsize=(9, 4))
for name, p in parsed.items():
if not p["train"]:
continue
xs = [t[0] for t in p["train"]]
ys = [t[2] for t in p["train"]]
ax.plot(xs, ys, color=p["color"], label=p["label"], lw=1.2, alpha=0.9)
ax.set_xlabel("epoch")
ax.set_ylabel("train CE loss")
ax.set_title("L15 diagnostic — training loss")
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
fig.tight_layout()
fig.savefig(OUT_DIR / "A_train_loss.png", dpi=140)
plt.close(fig)
# B: stage timeline
fig, ax = plt.subplots(figsize=(9, 4))
for name, p in parsed.items():
if not p["train"]:
continue
xs = [t[0] for t in p["train"]]
ys = [t[1] for t in p["train"]]
ax.step(xs, ys, where="post", color=p["color"], label=p["label"], lw=1.5)
for a, b, ep in p["promotes"]:
ax.axvline(ep, color=p["color"], alpha=0.15, lw=0.8)
ax.set_xlabel("epoch")
ax.set_ylabel("scheduled stage")
ax.set_title("L15 diagnostic — stage timeline (promote markers faint)")
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
fig.tight_layout()
fig.savefig(OUT_DIR / "B_stage_timeline.png", dpi=140)
plt.close(fig)
# C/D heatmaps per arm
for key, fname, title, vmin, vmax, cmap in [
("frontier", "C_frontier_heatmap", "frontier accuracy", 0, 1, "viridis"),
("ce_score", "D_ce_score_heatmap", "ce_score (BT metric)", 0, 1, "magma"),
]:
n_arms = len(parsed)
fig, axes = plt.subplots(1, n_arms, figsize=(4 * n_arms, 4), squeeze=False)
for ax, (name, p) in zip(axes[0], parsed.items()):
evs = p["evals"]
if not evs:
ax.set_title(p["label"] + " (no eval)")
continue
hops = sorted({h for e in evs for h in e["hops"]})
mat = np.full((len(hops), len(evs)), np.nan)
for j, e in enumerate(evs):
for i, h in enumerate(hops):
if h in e["hops"]:
mat[i, j] = e["hops"][h].get(key, np.nan)
im = ax.imshow(
mat,
aspect="auto",
origin="lower",
vmin=vmin,
vmax=vmax,
cmap=cmap,
interpolation="nearest",
)
ax.set_yticks(range(len(hops)))
ax.set_yticklabels([str(h) for h in hops], fontsize=7)
# x ticks: a few epochs
xt = np.linspace(0, max(len(evs) - 1, 0), num=min(6, len(evs)), dtype=int)
ax.set_xticks(xt)
ax.set_xticklabels([str(evs[j]["epoch"]) for j in xt], fontsize=7, rotation=45)
ax.set_xlabel("epoch")
ax.set_ylabel("hop")
ax.set_title(p["label"], fontsize=9)
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
fig.suptitle(f"L15 diagnostic — {title} over training")
fig.tight_layout()
fig.savefig(OUT_DIR / f"{fname}.png", dpi=140)
plt.close(fig)
# E: time to frontier thresholds
fig, ax = plt.subplots(figsize=(9, 4))
summary = {}
for name, p in parsed.items():
summary[name] = {}
for thr, ls in [(0.85, "--"), (0.95, "-")]:
tt = time_to_threshold(p["evals"], "frontier", thr)
summary[name][f"frontier>={thr}"] = tt
xs = [h for h, e in tt.items() if e is not None]
ys = [tt[h] for h in xs]
if xs:
ax.plot(
xs,
ys,
ls,
color=p["color"],
marker="o",
ms=3,
label=f"{p['label']} thr={thr}",
)
tt_ce = time_to_threshold(p["evals"], "ce_score", 0.5)
summary[name]["ce_score>=0.5"] = tt_ce
ax.set_xlabel("hop")
ax.set_ylabel("first epoch reaching threshold")
ax.set_title("L15 diagnostic — time-to-threshold (frontier)")
ax.legend(fontsize=7, ncol=2)
ax.grid(True, alpha=0.3)
fig.tight_layout()
fig.savefig(OUT_DIR / "E_time_to_threshold.png", dpi=140)
plt.close(fig)
# F: latest bars frontier vs ce
n_arms = len(parsed)
fig, axes = plt.subplots(1, n_arms, figsize=(4 * n_arms, 3.5), squeeze=False)
for ax, (name, p) in zip(axes[0], parsed.items()):
if not p["evals"]:
continue
last = p["evals"][-1]["hops"]
hops = sorted(last)
fr = [last[h]["frontier"] for h in hops]
ce = [last[h]["ce_score"] for h in hops]
x = np.arange(len(hops))
ax.bar(x - 0.2, fr, 0.4, label="frontier", color="#1b7837")
ax.bar(x + 0.2, ce, 0.4, label="ce_score", color="#d95f02")
ax.axhline(0.95, color="gray", ls="--", lw=0.8)
ax.axhline(0.5, color="#d95f02", ls=":", lw=0.8)
ax.set_xticks(x)
ax.set_xticklabels([str(h) for h in hops], fontsize=7)
ax.set_ylim(0, 1.05)
ax.set_title(
f"{p['label']}\nep{p['evals'][-1]['epoch']} stage={p['evals'][-1]['stage']}",
fontsize=8,
)
ax.legend(fontsize=7)
ax.grid(True, axis="y", alpha=0.3)
fig.suptitle("L15 diagnostic — latest per-hop frontier vs ce_score")
fig.tight_layout()
fig.savefig(OUT_DIR / "F_latest_bars.png", dpi=140)
plt.close(fig)
(OUT_DIR / "summary.json").write_text(json.dumps(summary, indent=2, default=str))
print(f"wrote plots -> {OUT_DIR}")
for name, p in parsed.items():
last_ep = p["train"][-1][0] if p["train"] else "?"
last_st = p["train"][-1][1] if p["train"] else "?"
n_prom = len(p["promotes"])
print(f" {p['label']}: epoch={last_ep} stage={last_st} promotes={n_prom} evals={len(p['evals'])}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--watch", type=int, default=0, help="replot every N seconds")
args = ap.parse_args()
if args.watch <= 0:
plot_all()
return
while True:
try:
plot_all()
except Exception as e:
print("plot error:", e)
time.sleep(args.watch)
if __name__ == "__main__":
main()