Text Generation
PEFT
Safetensors
lora
trl
grpo
gdpo
dpo
divpo
rlhf
diversity
creative-writing
mode-collapse
Instructions to use Mercity/creative-writing-llm with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use Mercity/creative-writing-llm with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
File size: 8,732 Bytes
cbc33fe | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | """
Post-run analysis for one GRPO arm. Produces logs/experiments/<name>.md plus
figures, from outputs/<name>/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 "{}"}
```

"""
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())
|