""" Post-run analysis for one GRPO arm. Produces logs/experiments/.md plus figures, from outputs//reward_history.json. Run this after EVERY training run, before launching the next one. The point is to catch a dead or hacked reward channel while there is still time to change something, rather than discovering it in the final report -- which is exactly how the prior run wasted 300 steps. """ from __future__ import annotations import argparse import json import sys from pathlib import Path import numpy as np ROOT = Path(__file__).resolve().parent.parent def smooth(x, w=15): x = np.asarray(x, dtype=np.float64) if len(x) < w: return x k = np.ones(w) / w return np.convolve(x, k, mode="valid") def trend(x, frac=0.25): """(early mean, late mean, delta) over the first/last `frac` of the run.""" x = np.asarray(x, dtype=np.float64) n = max(1, int(len(x) * frac)) a, b = float(x[:n].mean()), float(x[-n:].mean()) return a, b, b - a def main(): ap = argparse.ArgumentParser() ap.add_argument("--name", required=True) ap.add_argument("--title", default=None) args = ap.parse_args() import logbook run_dir = ROOT / "outputs" / args.name hist = json.load(open(run_dir / "reward_history.json")) if not hist: print("empty history"); return 1 steps = np.arange(len(hist)) series = {k: np.array([h[k] for h in hist], dtype=np.float64) for k in ("gate_pass", "ends_cleanly", "mean_quality_passing", "mean_novelty", "frac_above_tau", "mean_deviation", "mean_logdet", "mean_words", "frac_groups_degenerate")} tr = {k: trend(v) for k, v in series.items()} # --- reward-hacking diagnostic --------------------------------------- dev_a, dev_b, dev_d = tr["mean_deviation"] q_a, q_b, q_d = tr["mean_quality_passing"] g_a, g_b, g_d = tr["gate_pass"] hack = (dev_d > 0.02 and q_d < -0.5) or (g_b < 0.6 and g_d < -0.2) verdict = ("SUSPECTED REWARD HACKING" if hack else "healthy" if (g_b > 0.7 and q_d > -0.5) else "degraded but not hacking") # --- figures ---------------------------------------------------------- import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt # TRL-side metrics (entropy / KL) live in a separate history trl_path = run_dir / "trl_log_history.json" trl = json.load(open(trl_path)) if trl_path.exists() else [] ent = np.array([h["entropy"] for h in trl if "entropy" in h], dtype=np.float64) kl = np.array([h["kl"] for h in trl if "kl" in h], dtype=np.float64) fig, ax = plt.subplots(3, 3, figsize=(16, 11.5)) panels = [ ("gate_pass", "Gate pass rate", "#c0392b", (0, 1.05)), ("ends_cleanly", "Ends cleanly", "#27ae60", (0, 1.05)), ("mean_quality_passing", "Judge quality (gate-passers)", "#2980b9", None), ("mean_deviation", "Mean pairwise deviation d_i", "#8e44ad", None), ("mean_logdet", "Group log-det volume", "#d35400", None), ("frac_above_tau", "Frac above tau (diversity-eligible)", "#16a085", (0, 1.05)), ("mean_words", "Mean story length (words)", "#7f8c8d", None), ("frac_groups_degenerate", "Groups with no valid sample", "#c0392b", (0, 1.05)), ] axes = ax.ravel() for a, (key, title, c, ylim) in zip(axes, panels): v = series[key] a.plot(steps, v, alpha=0.25, color=c, lw=0.8) sm = smooth(v) a.plot(steps[len(steps) - len(sm):], sm, color=c, lw=2) a.set_title(title, fontsize=10) a.set_xlabel("reward-batch"); a.grid(alpha=.3) if ylim: a.set_ylim(*ylim) # entropy panel: the creativity-death detector. Falling entropy means the # policy is becoming deterministic. Rising entropy is permissive, NOT proof # of creativity -- a policy can get noisier inside a single narrative mode -- # so this is read alongside log-det, never as a substitute for it. a = axes[8] if ent.size: x = np.arange(ent.size) a.plot(x, ent, alpha=0.25, color="#e67e22", lw=0.8) sm = smooth(ent) a.plot(x[len(x) - len(sm):], sm, color="#e67e22", lw=2, label="entropy") e0, e1, ed = trend(ent) a.axhline(e0, ls=":", c="gray", lw=1) a.set_title(f"Per-token policy entropy ({e0:.3f} → {e1:.3f}, Δ{ed:+.3f})", fontsize=10) if kl.size: a2 = a.twinx() a2.plot(np.arange(kl.size), kl, color="#95a5a6", lw=1, alpha=.7) a2.set_ylabel("KL to ref", color="#95a5a6", fontsize=8) else: a.text(.5, .5, "no entropy logged\n(liger path?)", ha="center", va="center", transform=a.transAxes, color="crimson") a.set_title("Per-token policy entropy — MISSING", fontsize=10) a.set_xlabel("optimizer step"); a.grid(alpha=.3) fig.suptitle(f"{args.title or args.name} — training diagnostics ({verdict})", fontsize=12) plt.tight_layout() figp = logbook.FIGS / f"{args.name}_diagnostics.png" figp.parent.mkdir(parents=True, exist_ok=True) plt.savefig(figp, dpi=140) plt.close() # --- gate failure census --------------------------------------------- from collections import Counter cnt = Counter() for h in hist: for k, v in (h.get("reasons") or {}).items(): cnt[k] += v total_stories = sum(h["n"] for h in hist) rows = [{"metric": k, "early": round(a, 4), "late": round(b, 4), "delta": round(d, 4)} for k, (a, b, d) in tr.items()] if ent.size: a_, b_, d_ = trend(ent) rows.append({"metric": "policy_entropy", "early": round(a_, 4), "late": round(b_, 4), "delta": round(d_, 4)}) entropy_note = ( f"Per-token policy entropy moved {a_:.4f} → {b_:.4f} " f"(Δ {d_:+.4f}, {100*d_/max(a_,1e-9):+.1f}%). " + ("**Entropy collapse** — the policy is becoming deterministic; " "treat any diversity gain reported below with suspicion." if d_ < -0.15 * a_ else "No entropy collapse. Note that entropy holding up is a " "necessary but not sufficient condition for diversity: it " "permits varied output without demonstrating it.") ) else: entropy_note = ("Entropy was NOT logged for this run. TRL only emits it " "on the non-liger loss path; check `use_liger_kernel`.") cost = {} cp = run_dir / "judge_cost.json" if cp.exists(): cost = json.load(open(cp)) body = f"""# {args.title or args.name} — training analysis **Verdict: {verdict}** Reward batches: {len(hist)} | stories scored: {total_stories} ## Trend (first 25% vs last 25% of the run) {logbook.table(rows)} ## Gate failures (count over the whole run, {total_stories} stories) {logbook.table([{"reason": k, "count": v, "pct_of_stories": round(100*v/max(1,total_stories), 2)} for k, v in cnt.most_common()]) if cnt else "_No gate failures._"} ## Policy entropy {entropy_note} Entropy is read as an *asymmetric* signal here. A large fall is strong evidence that creativity is dying — the policy is collapsing toward deterministic output. A rise is only permissive: a policy can raise per-token entropy while staying inside one narrative mode (noisier word choice, same story). The prior run demonstrated exactly that dissociation — surface variation up, semantic diversity down. So entropy is never optimized, and diversity claims rest on log-det / effective rank. ## Reward-hacking check The signature we watch for is **diversity up while quality or validity goes down** — the policy discovering it can farm the diversity channel by emitting text that is different because it is worse. - mean deviation: {dev_a:.4f} → {dev_b:.4f} (Δ {dev_d:+.4f}) - judge quality: {q_a:.3f} → {q_b:.3f} (Δ {q_d:+.3f}) - gate pass: {g_a:.3f} → {g_b:.3f} (Δ {g_d:+.3f}) Trip conditions: `Δdeviation > +0.02 AND Δquality < -0.5`, or `gate_pass < 0.60 AND Δgate_pass < -0.20`. → **{"TRIPPED" if hack else "not tripped"}** ## Judge cost ```json {json.dumps(cost, indent=1) if cost else "{}"} ``` ![diagnostics](../figures/{args.name}_diagnostics.png) """ p = logbook.write_report(args.name, body) print(f"verdict: {verdict}") for r in rows: print(f" {r['metric']:26} {r['early']:>9.4f} -> {r['late']:>9.4f} ({r['delta']:+.4f})") print("report ->", p) print("figure ->", figp) logbook.note(f"analysis: {args.name}", f"verdict={verdict}; report {p}") return 0 if __name__ == "__main__": sys.exit(main())