| |
| """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" |
|
|
| |
| 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 {} |
| |
| 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 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, |
| ): |
| |
| pass |
|
|
| |
| 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 |
| |
| |
| 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 |
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|