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
Add src
Browse files- src/analyze_pool.py +235 -0
- src/analyze_prior_run.py +191 -0
- src/analyze_run.py +223 -0
- src/build_pairs.py +257 -0
- src/build_pool.py +161 -0
- src/calibrate_judge.py +230 -0
- src/ckpt_study.py +215 -0
- src/data.py +145 -0
- src/diversity.py +226 -0
- src/evaluate.py +320 -0
- src/gates.py +179 -0
- src/generate.py +156 -0
- src/judge.py +331 -0
- src/logbook.py +125 -0
- src/make_report.py +247 -0
- src/qualitative.py +157 -0
- src/rewards.py +301 -0
- src/smoke_vllm.py +59 -0
- src/status.sh +39 -0
- src/test_diversity.py +186 -0
- src/test_eval_metrics.py +174 -0
- src/test_pairs.py +167 -0
- src/test_rewards.py +174 -0
- src/train_dpo.py +191 -0
- src/train_grpo.py +232 -0
src/analyze_pool.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Characterize the base policy from the generation pool.
|
| 3 |
+
|
| 4 |
+
The pool is 1000 prompts x 16 samples = 16,000 stories, which is 20x the
|
| 5 |
+
held-out eval set. It is the most statistically solid picture of baseline mode
|
| 6 |
+
collapse in the whole study, so it gets its own report rather than being used
|
| 7 |
+
only as DPO feed.
|
| 8 |
+
|
| 9 |
+
Answers:
|
| 10 |
+
- How collapsed is the base model, per prompt? (deviation, log-det, eff. rank)
|
| 11 |
+
- Is collapse uniform, or are some prompts far worse than others?
|
| 12 |
+
- Does the judge's quality correlate with diversity? (i.e. is there really a
|
| 13 |
+
quality-diversity tension to trade off, or are they independent?)
|
| 14 |
+
- What does the quality distribution look like -- does tau=5 / rho=6 bite?
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import json
|
| 20 |
+
import sys
|
| 21 |
+
from collections import defaultdict
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
|
| 26 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main():
|
| 30 |
+
ap = argparse.ArgumentParser()
|
| 31 |
+
ap.add_argument("--tag", default="4b")
|
| 32 |
+
ap.add_argument("--split", default="train")
|
| 33 |
+
args = ap.parse_args()
|
| 34 |
+
|
| 35 |
+
import logbook
|
| 36 |
+
from diversity import effective_rank, logdet_volume, pairwise_deviation
|
| 37 |
+
|
| 38 |
+
pdir = ROOT / "outputs" / f"pool_{args.tag}"
|
| 39 |
+
rows = [json.loads(l) for l in open(pdir / f"pool_{args.split}.jsonl") if l.strip()]
|
| 40 |
+
E = np.load(pdir / f"emb_{args.split}.npy").astype(np.float64)
|
| 41 |
+
summary = json.load(open(pdir / f"summary_{args.split}.json"))
|
| 42 |
+
|
| 43 |
+
by = defaultdict(list)
|
| 44 |
+
for i, r in enumerate(rows):
|
| 45 |
+
by[r["prompt_id"]].append(i)
|
| 46 |
+
|
| 47 |
+
per = []
|
| 48 |
+
for pid, ids in by.items():
|
| 49 |
+
sub = E[ids]
|
| 50 |
+
q = np.array([rows[i]["quality"] for i in ids])
|
| 51 |
+
gp = np.array([rows[i]["gate_passed"] for i in ids])
|
| 52 |
+
per.append({
|
| 53 |
+
"prompt_id": pid,
|
| 54 |
+
"dev": float(pairwise_deviation(sub).mean()),
|
| 55 |
+
"logdet": float(logdet_volume(sub)),
|
| 56 |
+
"eff_rank": float(effective_rank(sub)),
|
| 57 |
+
"quality": float(q[gp].mean()) if gp.any() else 0.0,
|
| 58 |
+
"gate_pass": float(gp.mean()),
|
| 59 |
+
"n": len(ids),
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
dev = np.array([p["dev"] for p in per])
|
| 63 |
+
ld = np.array([p["logdet"] for p in per])
|
| 64 |
+
er = np.array([p["eff_rank"] for p in per])
|
| 65 |
+
ql = np.array([p["quality"] for p in per])
|
| 66 |
+
qual_all = np.array([r["quality"] for r in rows if r["gate_passed"]])
|
| 67 |
+
N = per[0]["n"]
|
| 68 |
+
|
| 69 |
+
# quality-diversity correlation across prompts
|
| 70 |
+
def corr(a, b):
|
| 71 |
+
if a.std() < 1e-9 or b.std() < 1e-9:
|
| 72 |
+
return 0.0
|
| 73 |
+
return float(np.corrcoef(a, b)[0, 1])
|
| 74 |
+
|
| 75 |
+
# --- how much signal do the E1 / E2 channels actually carry? -----------
|
| 76 |
+
# GDPO z-scores each reward channel WITHIN its group, so a channel with tiny
|
| 77 |
+
# within-group spread gets amplified to unit variance regardless. If the
|
| 78 |
+
# within-group ordering of d_i is not meaningful, E1 is largely learning
|
| 79 |
+
# from amplified noise. Compare within-group spread against between-group
|
| 80 |
+
# spread: a ratio far below 1 means the channel mostly encodes "which prompt
|
| 81 |
+
# is this", which per-group normalization deliberately removes.
|
| 82 |
+
dev_within, marg_within = [], []
|
| 83 |
+
for pid, ids in by.items():
|
| 84 |
+
sub = E[ids]
|
| 85 |
+
d = pairwise_deviation(sub)
|
| 86 |
+
from diversity import marginal_contributions
|
| 87 |
+
m = marginal_contributions(sub)
|
| 88 |
+
dev_within.append(d.std())
|
| 89 |
+
marg_within.append(m.std())
|
| 90 |
+
dev_within = np.array(dev_within); marg_within = np.array(marg_within)
|
| 91 |
+
|
| 92 |
+
stats = {
|
| 93 |
+
"n_prompts": len(per), "n_per_prompt": N, "n_stories": len(rows),
|
| 94 |
+
"dev_within_group_sd": float(dev_within.mean()),
|
| 95 |
+
"dev_between_group_sd": float(dev.std()),
|
| 96 |
+
"dev_within_over_between": float(dev_within.mean() / max(dev.std(), 1e-9)),
|
| 97 |
+
"marginal_within_group_sd": float(marg_within.mean()),
|
| 98 |
+
"gate_pass_rate": summary["gate_pass_rate"],
|
| 99 |
+
"ends_cleanly_rate": summary["ends_cleanly_rate"],
|
| 100 |
+
"quality_mean": float(qual_all.mean()), "quality_sd": float(qual_all.std()),
|
| 101 |
+
"quality_p10": float(np.percentile(qual_all, 10)),
|
| 102 |
+
"quality_median": float(np.median(qual_all)),
|
| 103 |
+
"quality_p90": float(np.percentile(qual_all, 90)),
|
| 104 |
+
"frac_quality_ge_5(tau)": float((qual_all >= 5).mean()),
|
| 105 |
+
"frac_quality_ge_6": float((qual_all >= 6).mean()),
|
| 106 |
+
"frac_quality_ge_7(rho)": float((qual_all >= 7).mean()),
|
| 107 |
+
"deviation_mean": float(dev.mean()), "deviation_sd": float(dev.std()),
|
| 108 |
+
"deviation_p10": float(np.percentile(dev, 10)),
|
| 109 |
+
"deviation_p90": float(np.percentile(dev, 90)),
|
| 110 |
+
"logdet_mean": float(ld.mean()), "logdet_sd": float(ld.std()),
|
| 111 |
+
"eff_rank_mean": float(er.mean()), "eff_rank_sd": float(er.std()),
|
| 112 |
+
"eff_rank_p10": float(np.percentile(er, 10)),
|
| 113 |
+
"eff_rank_p90": float(np.percentile(er, 90)),
|
| 114 |
+
"eff_rank_ceiling": N,
|
| 115 |
+
"corr(quality, deviation)": corr(ql, dev),
|
| 116 |
+
"corr(quality, eff_rank)": corr(ql, er),
|
| 117 |
+
"corr(deviation, eff_rank)": corr(dev, er),
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
# ---- figures ---------------------------------------------------------
|
| 121 |
+
import matplotlib
|
| 122 |
+
matplotlib.use("Agg")
|
| 123 |
+
import matplotlib.pyplot as plt
|
| 124 |
+
|
| 125 |
+
fig, ax = plt.subplots(1, 4, figsize=(19, 4.3))
|
| 126 |
+
ax[0].hist(qual_all, bins=np.arange(-0.25, 10.75, 0.5), color="#2980b9",
|
| 127 |
+
edgecolor="white")
|
| 128 |
+
ax[0].axvline(5, c="crimson", ls="--", label="tau=5 (diversity gate)")
|
| 129 |
+
ax[0].axvline(7, c="darkorange", ls="--", label="rho=7 (DivPO, swept)")
|
| 130 |
+
ax[0].set_title("Judge quality, all gate-passing stories")
|
| 131 |
+
ax[0].set_xlabel("quality"); ax[0].legend(fontsize=7); ax[0].grid(alpha=.3)
|
| 132 |
+
|
| 133 |
+
ax[1].hist(er, bins=30, color="#8e44ad", edgecolor="white")
|
| 134 |
+
ax[1].axvline(N, c="green", ls="--", label=f"ceiling = {N}")
|
| 135 |
+
ax[1].set_title(f"Effective rank per prompt\n(1 = total collapse, {N} = orthogonal)")
|
| 136 |
+
ax[1].set_xlabel("effective rank"); ax[1].legend(fontsize=7); ax[1].grid(alpha=.3)
|
| 137 |
+
|
| 138 |
+
ax[2].hist(dev, bins=30, color="#16a085", edgecolor="white")
|
| 139 |
+
ax[2].set_title("Mean pairwise distance per prompt")
|
| 140 |
+
ax[2].set_xlabel("1 - cos"); ax[2].grid(alpha=.3)
|
| 141 |
+
|
| 142 |
+
ax[3].scatter(er, ql, s=6, alpha=.35, color="#c0392b")
|
| 143 |
+
ax[3].set_xlabel("effective rank"); ax[3].set_ylabel("mean judge quality")
|
| 144 |
+
ax[3].set_title(f"Quality vs diversity across prompts\nr = {stats['corr(quality, eff_rank)']:+.3f}")
|
| 145 |
+
ax[3].grid(alpha=.3)
|
| 146 |
+
|
| 147 |
+
plt.tight_layout()
|
| 148 |
+
figp = logbook.FIGS / f"03_pool_{args.tag}_baseline.png"
|
| 149 |
+
plt.savefig(figp, dpi=140)
|
| 150 |
+
plt.close()
|
| 151 |
+
|
| 152 |
+
worst = sorted(per, key=lambda p: p["eff_rank"])[:5]
|
| 153 |
+
best = sorted(per, key=lambda p: -p["eff_rank"])[:5]
|
| 154 |
+
|
| 155 |
+
r_qd = stats["corr(quality, eff_rank)"]
|
| 156 |
+
tension = ("a genuine quality-diversity **tension**" if r_qd < -0.15 else
|
| 157 |
+
"quality and diversity are **largely independent**" if abs(r_qd) <= 0.15 else
|
| 158 |
+
"quality and diversity are **positively** related")
|
| 159 |
+
|
| 160 |
+
body = f"""# Baseline characterization — {args.tag} pool ({len(rows):,} stories)
|
| 161 |
+
|
| 162 |
+
The pool is {stats['n_prompts']} prompts x {N} samples from the **base policy**,
|
| 163 |
+
20x the held-out eval set. This is the most statistically solid picture of
|
| 164 |
+
baseline mode collapse in the study, so it is reported in its own right rather
|
| 165 |
+
than treated only as DPO feed.
|
| 166 |
+
|
| 167 |
+
## Summary
|
| 168 |
+
|
| 169 |
+
{logbook.table([{"metric": k, "value": v} for k, v in stats.items()])}
|
| 170 |
+
|
| 171 |
+
## Findings
|
| 172 |
+
|
| 173 |
+
**Baseline collapse is severe.** Mean effective rank is
|
| 174 |
+
**{stats['eff_rank_mean']:.2f} out of a ceiling of {N}** — the {N} samples for a
|
| 175 |
+
given prompt span only ~{stats['eff_rank_mean']:.1f} effective directions. Mean
|
| 176 |
+
pairwise distance is {stats['deviation_mean']:.3f}, i.e. same-prompt stories sit
|
| 177 |
+
at ~{1-stats['deviation_mean']:.2f} cosine similarity. This is the thing every
|
| 178 |
+
arm is trying to move.
|
| 179 |
+
|
| 180 |
+
**Collapse is not uniform across prompts.** Effective rank runs from
|
| 181 |
+
{stats['eff_rank_p10']:.2f} (p10) to {stats['eff_rank_p90']:.2f} (p90), so some
|
| 182 |
+
prompts admit far more variation than others. Per-group normalization (GDPO)
|
| 183 |
+
handles this correctly: each prompt's advantage is computed within its own
|
| 184 |
+
group, so an intrinsically constrained prompt does not drag the update.
|
| 185 |
+
|
| 186 |
+
**Quality vs diversity: r = {r_qd:+.3f}** across prompts, so {tension}. This
|
| 187 |
+
matters for reading the frontier: if the correlation is near zero, then a method
|
| 188 |
+
that raises diversity without lowering quality is not defying a tradeoff, it is
|
| 189 |
+
exploiting slack that was already there.
|
| 190 |
+
|
| 191 |
+
**How much signal does E1's channel carry?** Within-group SD of `d_i` is
|
| 192 |
+
{stats['dev_within_group_sd']:.4f} against a between-group SD of
|
| 193 |
+
{stats['dev_between_group_sd']:.4f} — a ratio of
|
| 194 |
+
**{stats['dev_within_over_between']:.2f}**. This matters because GDPO z-scores
|
| 195 |
+
each channel *within* its group, so whatever within-group spread exists is
|
| 196 |
+
amplified to unit variance. A low ratio means most of `d_i`'s variation encodes
|
| 197 |
+
*which prompt this is* rather than *which sample is the odd one out* — and
|
| 198 |
+
per-group normalization deliberately discards exactly the former. Read E1's
|
| 199 |
+
result with this number in mind: if E1 underperforms, weak within-group
|
| 200 |
+
resolution is the first hypothesis, not a refutation of pairwise diversity as an
|
| 201 |
+
idea. The marginal channel's within-group SD is
|
| 202 |
+
{stats['marginal_within_group_sd']:.4f} on the raw log scale (it is z-scored
|
| 203 |
+
before use, so only its ordering matters).
|
| 204 |
+
|
| 205 |
+
**Threshold placement.** {100*stats['frac_quality_ge_5(tau)']:.1f}% of
|
| 206 |
+
gate-passing stories score >= tau=5 and
|
| 207 |
+
{100*stats['frac_quality_ge_7(rho)']:.1f}% score >= rho=7 (swept up from 6 because only {100*(1-stats['frac_quality_ge_6']):.1f}% fall below 6, starving DivPO of negatives on 311/1000 prompts). tau therefore acts as
|
| 208 |
+
a *floor* that only bites when a story is genuinely bad, which is its intent
|
| 209 |
+
(anti-gaming, not selection). rho is the more selective threshold and determines
|
| 210 |
+
DivPO's skip rate.
|
| 211 |
+
|
| 212 |
+
## Most collapsed prompts
|
| 213 |
+
|
| 214 |
+
{logbook.table(worst, ["prompt_id", "eff_rank", "dev", "logdet", "quality"])}
|
| 215 |
+
|
| 216 |
+
## Most diverse prompts
|
| 217 |
+
|
| 218 |
+
{logbook.table(best, ["prompt_id", "eff_rank", "dev", "logdet", "quality"])}
|
| 219 |
+
|
| 220 |
+

|
| 221 |
+
"""
|
| 222 |
+
p = logbook.write_report(f"03_pool_{args.tag}_baseline", body)
|
| 223 |
+
print(json.dumps(stats, indent=1))
|
| 224 |
+
print("report ->", p, "\nfigure ->", figp)
|
| 225 |
+
json.dump({"stats": stats, "per_prompt": per},
|
| 226 |
+
open(logbook.LOGS / f"pool_{args.tag}_analysis.json", "w"), indent=1)
|
| 227 |
+
logbook.note(f"pool analysis ({args.tag})",
|
| 228 |
+
f"eff_rank {stats['eff_rank_mean']:.2f}/{N}, "
|
| 229 |
+
f"dev {stats['deviation_mean']:.3f}, "
|
| 230 |
+
f"corr(q,div) {r_qd:+.3f}")
|
| 231 |
+
return 0
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
if __name__ == "__main__":
|
| 235 |
+
sys.exit(main())
|
src/analyze_prior_run.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Autopsy of the previous run (logs/experiments/00_prior_run_autopsy.md + figures).
|
| 3 |
+
|
| 4 |
+
Reads prior_run/eval_samples/step_*.json, which are {prompt: [16 stories]} dumps
|
| 5 |
+
taken every 50 steps. Recomputes gates + embedding diversity so the prior run is
|
| 6 |
+
measured with EXACTLY the same instruments as the new arms -- otherwise the
|
| 7 |
+
"before" number is not comparable to any "after" number.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import glob
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
from collections import Counter
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
import gates
|
| 20 |
+
import logbook
|
| 21 |
+
from diversity import group_metrics, l2_normalize
|
| 22 |
+
|
| 23 |
+
PRIOR = Path(__file__).resolve().parent.parent / "prior_run" / "eval_samples"
|
| 24 |
+
EMB_MODEL = "BAAI/bge-base-en-v1.5"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_steps() -> dict[int, dict[str, list[str]]]:
|
| 28 |
+
out = {}
|
| 29 |
+
for f in sorted(glob.glob(str(PRIOR / "step_*.json")),
|
| 30 |
+
key=lambda x: int(re.search(r"\d+", Path(x).name).group())):
|
| 31 |
+
step = int(re.search(r"\d+", Path(f).name).group())
|
| 32 |
+
out[step] = json.load(open(f))
|
| 33 |
+
return out
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def main():
|
| 37 |
+
steps = load_steps()
|
| 38 |
+
print(f"loaded {len(steps)} checkpoints: {sorted(steps)}")
|
| 39 |
+
|
| 40 |
+
from sentence_transformers import SentenceTransformer
|
| 41 |
+
enc = SentenceTransformer(EMB_MODEL, device="cuda")
|
| 42 |
+
|
| 43 |
+
rows = []
|
| 44 |
+
reason_counter: dict[int, Counter] = {}
|
| 45 |
+
for step, data in steps.items():
|
| 46 |
+
allres, pw, ld, mm = [], [], [], []
|
| 47 |
+
for prompt, samples in data.items():
|
| 48 |
+
res = [gates.check(s) for s in samples]
|
| 49 |
+
allres += res
|
| 50 |
+
E = l2_normalize(enc.encode(samples, normalize_embeddings=True,
|
| 51 |
+
batch_size=32, show_progress_bar=False))
|
| 52 |
+
gm = group_metrics(E)
|
| 53 |
+
pw.append(gm["mean_pairwise_dist"]); ld.append(gm["logdet"])
|
| 54 |
+
mm.append(gm["mean_marginal"])
|
| 55 |
+
n = len(allres)
|
| 56 |
+
reason_counter[step] = Counter(r for x in allres for r in x.reasons)
|
| 57 |
+
rows.append({
|
| 58 |
+
"step": step, "n": n,
|
| 59 |
+
"gate_pass_%": 100 * sum(r.passed for r in allres) / n,
|
| 60 |
+
"ends_cleanly_%": 100 * sum(r.completeness for r in allres) / n,
|
| 61 |
+
"med_words": float(np.median([r.n_words for r in allres])),
|
| 62 |
+
"rep4gram": float(np.mean([r.repeat_4gram_frac for r in allres])),
|
| 63 |
+
"pairwise_dist": float(np.mean(pw)),
|
| 64 |
+
"logdet": float(np.mean(ld)),
|
| 65 |
+
"mean_marginal": float(np.mean(mm)),
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
for r in rows:
|
| 69 |
+
print(r)
|
| 70 |
+
|
| 71 |
+
# ---------------------------------------------------------------- figures
|
| 72 |
+
import matplotlib
|
| 73 |
+
matplotlib.use("Agg")
|
| 74 |
+
import matplotlib.pyplot as plt
|
| 75 |
+
|
| 76 |
+
s = [r["step"] for r in rows]
|
| 77 |
+
fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))
|
| 78 |
+
|
| 79 |
+
ax[0].plot(s, [r["ends_cleanly_%"] for r in rows], "o-", color="#c0392b", lw=2)
|
| 80 |
+
ax[0].axhline(80, ls="--", c="gray", lw=1)
|
| 81 |
+
ax[0].set_title("Completeness collapse\n(prior run)")
|
| 82 |
+
ax[0].set_xlabel("step"); ax[0].set_ylabel("% ending cleanly")
|
| 83 |
+
ax[0].set_ylim(-3, 100); ax[0].grid(alpha=.3)
|
| 84 |
+
ax[0].text(60, 84, "healthy target", color="gray", fontsize=8)
|
| 85 |
+
|
| 86 |
+
ax[1].plot(s, [r["pairwise_dist"] for r in rows], "o-", label="pairwise dist", lw=2)
|
| 87 |
+
ax[1].set_xlabel("step"); ax[1].set_ylabel("mean pairwise distance", color="C0")
|
| 88 |
+
ax2 = ax[1].twinx()
|
| 89 |
+
ax2.plot(s, [r["logdet"] for r in rows], "s--", color="C1", label="logdet")
|
| 90 |
+
ax2.set_ylabel("logdet volume", color="C1")
|
| 91 |
+
ax[1].set_title("Semantic diversity\n(embedding-based)")
|
| 92 |
+
ax[1].grid(alpha=.3)
|
| 93 |
+
|
| 94 |
+
ax[2].plot(s, [r["med_words"] for r in rows], "o-", color="#2c3e50", lw=2)
|
| 95 |
+
ax[2].set_title("Median length pinned at the\ntoken wall (~380 words)")
|
| 96 |
+
ax[2].set_xlabel("step"); ax[2].set_ylabel("median words")
|
| 97 |
+
ax[2].grid(alpha=.3)
|
| 98 |
+
|
| 99 |
+
plt.tight_layout()
|
| 100 |
+
out = logbook.FIGS / "00_prior_run_autopsy.png"
|
| 101 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 102 |
+
plt.savefig(out, dpi=140)
|
| 103 |
+
print("figure ->", out)
|
| 104 |
+
|
| 105 |
+
# ---------------------------------------------------------------- report
|
| 106 |
+
body = f"""# Experiment 00 — Autopsy of the prior run
|
| 107 |
+
|
| 108 |
+
**Purpose.** Establish what actually happened in the previous `train.py` run
|
| 109 |
+
before building anything new, and re-measure it with the *same* instruments the
|
| 110 |
+
new arms will use, so "before" and "after" are comparable.
|
| 111 |
+
|
| 112 |
+
**Source.** `prior_run/eval_samples/step_{{50..300}}.json` — 5 held-out prompts x
|
| 113 |
+
16 samples, dumped every 50 steps. 150 stories total per checkpoint set.
|
| 114 |
+
|
| 115 |
+
## Measurements
|
| 116 |
+
|
| 117 |
+
{logbook.table(rows)}
|
| 118 |
+
|
| 119 |
+
## Failure-reason census (gate violations, counted per story)
|
| 120 |
+
|
| 121 |
+
{logbook.table([{"step": k, **dict(v)} for k, v in reason_counter.items()])}
|
| 122 |
+
|
| 123 |
+
## Findings
|
| 124 |
+
|
| 125 |
+
**1. The run generated no finished stories, at any checkpoint.**
|
| 126 |
+
`ends_cleanly` is 0-8% across all six checkpoints. Median length sits in a
|
| 127 |
+
tight ~360-400 word band with a hard ceiling, which is the signature of a
|
| 128 |
+
`max_new_tokens` wall rather than a stylistic preference. Sampled tails
|
| 129 |
+
confirm mid-word cutoff (`"...a place called *The Library of"`).
|
| 130 |
+
|
| 131 |
+
**2. That silently destroyed the quality reward.**
|
| 132 |
+
The prior judge rubric said *"if the story cuts off mid-sentence, cap quality
|
| 133 |
+
at 4."* When ~100% of a group hits the same cap, within-group quality variance
|
| 134 |
+
goes to ~0. GRPO normalizes advantages within the group, so an
|
| 135 |
+
(almost) constant reward column produces an (almost) zero advantage. The
|
| 136 |
+
quality objective contributed no gradient for the entire run.
|
| 137 |
+
|
| 138 |
+
**3. The group-diversity term was structurally incapable of contributing.**
|
| 139 |
+
`quality_reward` added `0.5 * group_diversity`, a single scalar identical for
|
| 140 |
+
every sample in the group. Its within-group std is exactly 0, so after
|
| 141 |
+
normalization it contributes exactly 0 to every advantage. It cost one judge
|
| 142 |
+
call per step and bought nothing. (This is the failure mode recorded in the
|
| 143 |
+
brief's section 0.1, confirmed here in situ.)
|
| 144 |
+
|
| 145 |
+
**4. Net effect: the run optimized novelty almost alone.**
|
| 146 |
+
With quality flat and group-diversity annihilated, the only surviving gradient
|
| 147 |
+
came from `novelty_reward` — and its quality gate (`q >= 5`) was itself
|
| 148 |
+
degenerate, because quality was pinned near the truncation cap. That is an
|
| 149 |
+
unconditioned novelty objective, which is precisely the configuration known to
|
| 150 |
+
produce surface-level gaming.
|
| 151 |
+
|
| 152 |
+
**5. The observable signature matches that diagnosis.**
|
| 153 |
+
Unique 5-word openers rise 0.68 -> 0.96 while type-token ratio *falls*
|
| 154 |
+
0.381 -> 0.343. The model learned to vary the first few words while its
|
| 155 |
+
vocabulary narrowed — diversification at the surface, homogenization
|
| 156 |
+
underneath. Heavy italic emphasis persists at ~7 instances/story throughout,
|
| 157 |
+
the exact tic the rubric explicitly tried to punish.
|
| 158 |
+
|
| 159 |
+
**6. A latent bug would have suppressed completeness credit even after a fix.**
|
| 160 |
+
`completeness_reward` tested `text[-1] in ".!?\\"'"` — straight quotes only.
|
| 161 |
+
These generations use curly U+201D constantly, so a story ending `...gone.”`
|
| 162 |
+
scored 0.3 instead of 1.0.
|
| 163 |
+
|
| 164 |
+
## Consequences for the new design
|
| 165 |
+
|
| 166 |
+
- Token budget raised to 1280 new tokens (a 500-word story is ~650-700 Qwen
|
| 167 |
+
tokens; the planned 768 left no headroom and would have re-created the bug).
|
| 168 |
+
Nothing truncates the story anywhere downstream — not the judge, not the
|
| 169 |
+
embedder. Overrun is *detected* by a word-count gate, never chopped.
|
| 170 |
+
- System prompt now carries an explicit word budget and ending instruction.
|
| 171 |
+
- No set-level scalar is ever used as a per-sample reward. All diversity credit
|
| 172 |
+
is per-sample by construction (deviation `d_i`, marginal `m_i`).
|
| 173 |
+
- `finish_reason == "length"` from vLLM is captured and gates on it directly,
|
| 174 |
+
rather than inferring truncation from punctuation.
|
| 175 |
+
- Terminal-punctuation detection handles curly quotes, ellipses and trailing
|
| 176 |
+
markdown emphasis.
|
| 177 |
+
- Judge scores one story at a time on an absolute rubric, so `tau` is
|
| 178 |
+
meaningful and cross-model eval comparisons are valid.
|
| 179 |
+
|
| 180 |
+

|
| 181 |
+
"""
|
| 182 |
+
p = logbook.write_report("00_prior_run_autopsy", body)
|
| 183 |
+
print("report ->", p)
|
| 184 |
+
logbook.note("prior-run autopsy complete",
|
| 185 |
+
f"0-8% completion across all checkpoints; quality signal was "
|
| 186 |
+
f"degenerate. Report: {p}")
|
| 187 |
+
json.dump(rows, open(logbook.LOGS / "prior_run_metrics.json", "w"), indent=2)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
if __name__ == "__main__":
|
| 191 |
+
main()
|
src/analyze_run.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Post-run analysis for one GRPO arm. Produces logs/experiments/<name>.md plus
|
| 3 |
+
figures, from outputs/<name>/reward_history.json.
|
| 4 |
+
|
| 5 |
+
Run this after EVERY training run, before launching the next one. The point is
|
| 6 |
+
to catch a dead or hacked reward channel while there is still time to change
|
| 7 |
+
something, rather than discovering it in the final report -- which is exactly
|
| 8 |
+
how the prior run wasted 300 steps.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def smooth(x, w=15):
|
| 23 |
+
x = np.asarray(x, dtype=np.float64)
|
| 24 |
+
if len(x) < w:
|
| 25 |
+
return x
|
| 26 |
+
k = np.ones(w) / w
|
| 27 |
+
return np.convolve(x, k, mode="valid")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def trend(x, frac=0.25):
|
| 31 |
+
"""(early mean, late mean, delta) over the first/last `frac` of the run."""
|
| 32 |
+
x = np.asarray(x, dtype=np.float64)
|
| 33 |
+
n = max(1, int(len(x) * frac))
|
| 34 |
+
a, b = float(x[:n].mean()), float(x[-n:].mean())
|
| 35 |
+
return a, b, b - a
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main():
|
| 39 |
+
ap = argparse.ArgumentParser()
|
| 40 |
+
ap.add_argument("--name", required=True)
|
| 41 |
+
ap.add_argument("--title", default=None)
|
| 42 |
+
args = ap.parse_args()
|
| 43 |
+
|
| 44 |
+
import logbook
|
| 45 |
+
|
| 46 |
+
run_dir = ROOT / "outputs" / args.name
|
| 47 |
+
hist = json.load(open(run_dir / "reward_history.json"))
|
| 48 |
+
if not hist:
|
| 49 |
+
print("empty history"); return 1
|
| 50 |
+
|
| 51 |
+
steps = np.arange(len(hist))
|
| 52 |
+
series = {k: np.array([h[k] for h in hist], dtype=np.float64) for k in
|
| 53 |
+
("gate_pass", "ends_cleanly", "mean_quality_passing", "mean_novelty",
|
| 54 |
+
"frac_above_tau", "mean_deviation", "mean_logdet", "mean_words",
|
| 55 |
+
"frac_groups_degenerate")}
|
| 56 |
+
|
| 57 |
+
tr = {k: trend(v) for k, v in series.items()}
|
| 58 |
+
|
| 59 |
+
# --- reward-hacking diagnostic ---------------------------------------
|
| 60 |
+
dev_a, dev_b, dev_d = tr["mean_deviation"]
|
| 61 |
+
q_a, q_b, q_d = tr["mean_quality_passing"]
|
| 62 |
+
g_a, g_b, g_d = tr["gate_pass"]
|
| 63 |
+
hack = (dev_d > 0.02 and q_d < -0.5) or (g_b < 0.6 and g_d < -0.2)
|
| 64 |
+
verdict = ("SUSPECTED REWARD HACKING" if hack else
|
| 65 |
+
"healthy" if (g_b > 0.7 and q_d > -0.5) else "degraded but not hacking")
|
| 66 |
+
|
| 67 |
+
# --- figures ----------------------------------------------------------
|
| 68 |
+
import matplotlib
|
| 69 |
+
matplotlib.use("Agg")
|
| 70 |
+
import matplotlib.pyplot as plt
|
| 71 |
+
|
| 72 |
+
# TRL-side metrics (entropy / KL) live in a separate history
|
| 73 |
+
trl_path = run_dir / "trl_log_history.json"
|
| 74 |
+
trl = json.load(open(trl_path)) if trl_path.exists() else []
|
| 75 |
+
ent = np.array([h["entropy"] for h in trl if "entropy" in h], dtype=np.float64)
|
| 76 |
+
kl = np.array([h["kl"] for h in trl if "kl" in h], dtype=np.float64)
|
| 77 |
+
|
| 78 |
+
fig, ax = plt.subplots(3, 3, figsize=(16, 11.5))
|
| 79 |
+
panels = [
|
| 80 |
+
("gate_pass", "Gate pass rate", "#c0392b", (0, 1.05)),
|
| 81 |
+
("ends_cleanly", "Ends cleanly", "#27ae60", (0, 1.05)),
|
| 82 |
+
("mean_quality_passing", "Judge quality (gate-passers)", "#2980b9", None),
|
| 83 |
+
("mean_deviation", "Mean pairwise deviation d_i", "#8e44ad", None),
|
| 84 |
+
("mean_logdet", "Group log-det volume", "#d35400", None),
|
| 85 |
+
("frac_above_tau", "Frac above tau (diversity-eligible)", "#16a085", (0, 1.05)),
|
| 86 |
+
("mean_words", "Mean story length (words)", "#7f8c8d", None),
|
| 87 |
+
("frac_groups_degenerate", "Groups with no valid sample", "#c0392b", (0, 1.05)),
|
| 88 |
+
]
|
| 89 |
+
axes = ax.ravel()
|
| 90 |
+
for a, (key, title, c, ylim) in zip(axes, panels):
|
| 91 |
+
v = series[key]
|
| 92 |
+
a.plot(steps, v, alpha=0.25, color=c, lw=0.8)
|
| 93 |
+
sm = smooth(v)
|
| 94 |
+
a.plot(steps[len(steps) - len(sm):], sm, color=c, lw=2)
|
| 95 |
+
a.set_title(title, fontsize=10)
|
| 96 |
+
a.set_xlabel("reward-batch"); a.grid(alpha=.3)
|
| 97 |
+
if ylim:
|
| 98 |
+
a.set_ylim(*ylim)
|
| 99 |
+
|
| 100 |
+
# entropy panel: the creativity-death detector. Falling entropy means the
|
| 101 |
+
# policy is becoming deterministic. Rising entropy is permissive, NOT proof
|
| 102 |
+
# of creativity -- a policy can get noisier inside a single narrative mode --
|
| 103 |
+
# so this is read alongside log-det, never as a substitute for it.
|
| 104 |
+
a = axes[8]
|
| 105 |
+
if ent.size:
|
| 106 |
+
x = np.arange(ent.size)
|
| 107 |
+
a.plot(x, ent, alpha=0.25, color="#e67e22", lw=0.8)
|
| 108 |
+
sm = smooth(ent)
|
| 109 |
+
a.plot(x[len(x) - len(sm):], sm, color="#e67e22", lw=2, label="entropy")
|
| 110 |
+
e0, e1, ed = trend(ent)
|
| 111 |
+
a.axhline(e0, ls=":", c="gray", lw=1)
|
| 112 |
+
a.set_title(f"Per-token policy entropy ({e0:.3f} → {e1:.3f}, Δ{ed:+.3f})",
|
| 113 |
+
fontsize=10)
|
| 114 |
+
if kl.size:
|
| 115 |
+
a2 = a.twinx()
|
| 116 |
+
a2.plot(np.arange(kl.size), kl, color="#95a5a6", lw=1, alpha=.7)
|
| 117 |
+
a2.set_ylabel("KL to ref", color="#95a5a6", fontsize=8)
|
| 118 |
+
else:
|
| 119 |
+
a.text(.5, .5, "no entropy logged\n(liger path?)", ha="center",
|
| 120 |
+
va="center", transform=a.transAxes, color="crimson")
|
| 121 |
+
a.set_title("Per-token policy entropy — MISSING", fontsize=10)
|
| 122 |
+
a.set_xlabel("optimizer step"); a.grid(alpha=.3)
|
| 123 |
+
fig.suptitle(f"{args.title or args.name} — training diagnostics ({verdict})",
|
| 124 |
+
fontsize=12)
|
| 125 |
+
plt.tight_layout()
|
| 126 |
+
figp = logbook.FIGS / f"{args.name}_diagnostics.png"
|
| 127 |
+
figp.parent.mkdir(parents=True, exist_ok=True)
|
| 128 |
+
plt.savefig(figp, dpi=140)
|
| 129 |
+
plt.close()
|
| 130 |
+
|
| 131 |
+
# --- gate failure census ---------------------------------------------
|
| 132 |
+
from collections import Counter
|
| 133 |
+
cnt = Counter()
|
| 134 |
+
for h in hist:
|
| 135 |
+
for k, v in (h.get("reasons") or {}).items():
|
| 136 |
+
cnt[k] += v
|
| 137 |
+
total_stories = sum(h["n"] for h in hist)
|
| 138 |
+
|
| 139 |
+
rows = [{"metric": k, "early": round(a, 4), "late": round(b, 4),
|
| 140 |
+
"delta": round(d, 4)} for k, (a, b, d) in tr.items()]
|
| 141 |
+
if ent.size:
|
| 142 |
+
a_, b_, d_ = trend(ent)
|
| 143 |
+
rows.append({"metric": "policy_entropy", "early": round(a_, 4),
|
| 144 |
+
"late": round(b_, 4), "delta": round(d_, 4)})
|
| 145 |
+
entropy_note = (
|
| 146 |
+
f"Per-token policy entropy moved {a_:.4f} → {b_:.4f} "
|
| 147 |
+
f"(Δ {d_:+.4f}, {100*d_/max(a_,1e-9):+.1f}%). "
|
| 148 |
+
+ ("**Entropy collapse** — the policy is becoming deterministic; "
|
| 149 |
+
"treat any diversity gain reported below with suspicion."
|
| 150 |
+
if d_ < -0.15 * a_ else
|
| 151 |
+
"No entropy collapse. Note that entropy holding up is a "
|
| 152 |
+
"necessary but not sufficient condition for diversity: it "
|
| 153 |
+
"permits varied output without demonstrating it.")
|
| 154 |
+
)
|
| 155 |
+
else:
|
| 156 |
+
entropy_note = ("Entropy was NOT logged for this run. TRL only emits it "
|
| 157 |
+
"on the non-liger loss path; check `use_liger_kernel`.")
|
| 158 |
+
|
| 159 |
+
cost = {}
|
| 160 |
+
cp = run_dir / "judge_cost.json"
|
| 161 |
+
if cp.exists():
|
| 162 |
+
cost = json.load(open(cp))
|
| 163 |
+
|
| 164 |
+
body = f"""# {args.title or args.name} — training analysis
|
| 165 |
+
|
| 166 |
+
**Verdict: {verdict}**
|
| 167 |
+
|
| 168 |
+
Reward batches: {len(hist)} | stories scored: {total_stories}
|
| 169 |
+
|
| 170 |
+
## Trend (first 25% vs last 25% of the run)
|
| 171 |
+
|
| 172 |
+
{logbook.table(rows)}
|
| 173 |
+
|
| 174 |
+
## Gate failures (count over the whole run, {total_stories} stories)
|
| 175 |
+
|
| 176 |
+
{logbook.table([{"reason": k, "count": v, "pct_of_stories": round(100*v/max(1,total_stories), 2)}
|
| 177 |
+
for k, v in cnt.most_common()]) if cnt else "_No gate failures._"}
|
| 178 |
+
|
| 179 |
+
## Policy entropy
|
| 180 |
+
|
| 181 |
+
{entropy_note}
|
| 182 |
+
|
| 183 |
+
Entropy is read as an *asymmetric* signal here. A large fall is strong evidence
|
| 184 |
+
that creativity is dying — the policy is collapsing toward deterministic output.
|
| 185 |
+
A rise is only permissive: a policy can raise per-token entropy while staying
|
| 186 |
+
inside one narrative mode (noisier word choice, same story). The prior run
|
| 187 |
+
demonstrated exactly that dissociation — surface variation up, semantic
|
| 188 |
+
diversity down. So entropy is never optimized, and diversity claims rest on
|
| 189 |
+
log-det / effective rank.
|
| 190 |
+
|
| 191 |
+
## Reward-hacking check
|
| 192 |
+
|
| 193 |
+
The signature we watch for is **diversity up while quality or validity goes
|
| 194 |
+
down** — the policy discovering it can farm the diversity channel by emitting
|
| 195 |
+
text that is different because it is worse.
|
| 196 |
+
|
| 197 |
+
- mean deviation: {dev_a:.4f} → {dev_b:.4f} (Δ {dev_d:+.4f})
|
| 198 |
+
- judge quality: {q_a:.3f} → {q_b:.3f} (Δ {q_d:+.3f})
|
| 199 |
+
- gate pass: {g_a:.3f} → {g_b:.3f} (Δ {g_d:+.3f})
|
| 200 |
+
|
| 201 |
+
Trip conditions: `Δdeviation > +0.02 AND Δquality < -0.5`, or
|
| 202 |
+
`gate_pass < 0.60 AND Δgate_pass < -0.20`. → **{"TRIPPED" if hack else "not tripped"}**
|
| 203 |
+
|
| 204 |
+
## Judge cost
|
| 205 |
+
|
| 206 |
+
```json
|
| 207 |
+
{json.dumps(cost, indent=1) if cost else "{}"}
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+

|
| 211 |
+
"""
|
| 212 |
+
p = logbook.write_report(args.name, body)
|
| 213 |
+
print(f"verdict: {verdict}")
|
| 214 |
+
for r in rows:
|
| 215 |
+
print(f" {r['metric']:26} {r['early']:>9.4f} -> {r['late']:>9.4f} ({r['delta']:+.4f})")
|
| 216 |
+
print("report ->", p)
|
| 217 |
+
print("figure ->", figp)
|
| 218 |
+
logbook.note(f"analysis: {args.name}", f"verdict={verdict}; report {p}")
|
| 219 |
+
return 0
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
if __name__ == "__main__":
|
| 223 |
+
sys.exit(main())
|
src/build_pairs.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Preference-pair construction for E3 (multi-positive diverse DPO) and
|
| 3 |
+
E4 (faithful DivPO). Both read the SAME scored pool, so the comparison isolates
|
| 4 |
+
pair construction + loss, with data held fixed.
|
| 5 |
+
|
| 6 |
+
E4 -- DivPO (Lanchantin et al. 2025), implemented as published
|
| 7 |
+
------------------------------------------------------------
|
| 8 |
+
One pair per prompt, no loss weighting, no multi-positive rows:
|
| 9 |
+
chosen = argmax diversity over {quality >= rho}
|
| 10 |
+
rejected = argmin diversity over {quality < rho}
|
| 11 |
+
Prompts where either side is empty are SKIPPED (skip rate is logged; if it
|
| 12 |
+
exceeds 30% we adjust rho once and record the change).
|
| 13 |
+
|
| 14 |
+
Two diversity criteria:
|
| 15 |
+
divpo-emb -- deviation d_i from the embedding module.
|
| 16 |
+
divpo-prob -- model probability. Most diverse = LOWEST length-normalized
|
| 17 |
+
logprob, least diverse = HIGHEST. The highest-probability
|
| 18 |
+
sample in a temperature pool is by construction the
|
| 19 |
+
near-greedy one, which makes this the principled version of
|
| 20 |
+
"reject the greedy decode".
|
| 21 |
+
|
| 22 |
+
Length normalization is not optional here: raw cumulative logprob scales with
|
| 23 |
+
token count, so ranking on it would rank by length and the "most diverse"
|
| 24 |
+
choice would just be the longest story.
|
| 25 |
+
|
| 26 |
+
E3 -- multi-positive deviation-weighted DPO
|
| 27 |
+
-------------------------------------------
|
| 28 |
+
survivors = {quality >= q_keep}
|
| 29 |
+
chosen = greedy argmax over 4-subsets of sum(quality) + lam * logdet(L_S)
|
| 30 |
+
rejected = r_D "competent cliche" (highest-quality LOW-deviation survivor)
|
| 31 |
+
+ r_Q "clearly weak" (lowest-quality sample overall)
|
| 32 |
+
Rows pair each chosen against a rejected, ROTATING the rejected across rows so
|
| 33 |
+
one negative is not hammered four times.
|
| 34 |
+
|
| 35 |
+
Each row carries `weight` = the chosen's deviation d_i (DDPO-style), consumed
|
| 36 |
+
by the trainer as a per-sample loss weight.
|
| 37 |
+
"""
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
|
| 40 |
+
import argparse
|
| 41 |
+
import json
|
| 42 |
+
import sys
|
| 43 |
+
from collections import defaultdict
|
| 44 |
+
from pathlib import Path
|
| 45 |
+
|
| 46 |
+
import numpy as np
|
| 47 |
+
|
| 48 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def load_pool(tag: str, split: str = "train") -> dict[str, list[dict]]:
|
| 52 |
+
p = ROOT / "outputs" / f"pool_{tag}" / f"pool_{split}.jsonl"
|
| 53 |
+
rows = [json.loads(l) for l in open(p) if l.strip()]
|
| 54 |
+
by: dict[str, list[dict]] = defaultdict(list)
|
| 55 |
+
for r in rows:
|
| 56 |
+
by[r["prompt_id"]].append(r)
|
| 57 |
+
return dict(by)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def load_emb(tag: str, split: str = "train") -> np.ndarray:
|
| 61 |
+
return np.load(ROOT / "outputs" / f"pool_{tag}" / f"emb_{split}.npy")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# --------------------------------------------------------------------- E4
|
| 65 |
+
def build_divpo(pool: dict[str, list[dict]], criterion: str, rho: float) -> tuple[list[dict], dict]:
|
| 66 |
+
"""criterion: 'emb' (deviation) or 'prob' (mean logprob)."""
|
| 67 |
+
rows, skipped = [], {"no_chosen": 0, "no_rejected": 0, "ok": 0}
|
| 68 |
+
|
| 69 |
+
for pid, items in pool.items():
|
| 70 |
+
valid = [r for r in items if r["gate_passed"]]
|
| 71 |
+
hi = [r for r in valid if r["quality"] >= rho]
|
| 72 |
+
# rejected pool: below the quality bar. Gate failures are legitimate
|
| 73 |
+
# DivPO rejects (they are the low-quality tail), so they are eligible.
|
| 74 |
+
lo = [r for r in items if not (r["gate_passed"] and r["quality"] >= rho)]
|
| 75 |
+
if not hi:
|
| 76 |
+
skipped["no_chosen"] += 1; continue
|
| 77 |
+
if not lo:
|
| 78 |
+
skipped["no_rejected"] += 1; continue
|
| 79 |
+
|
| 80 |
+
if criterion == "emb":
|
| 81 |
+
chosen = max(hi, key=lambda r: r["deviation"])
|
| 82 |
+
rejected = max(lo, key=lambda r: -r["deviation"]) # least diverse
|
| 83 |
+
elif criterion == "prob":
|
| 84 |
+
# most diverse == least probable; least diverse == most probable
|
| 85 |
+
chosen = min(hi, key=lambda r: r["mean_logprob"])
|
| 86 |
+
rejected = max(lo, key=lambda r: r["mean_logprob"])
|
| 87 |
+
else:
|
| 88 |
+
raise ValueError(criterion)
|
| 89 |
+
|
| 90 |
+
rows.append({
|
| 91 |
+
"prompt_id": pid, "prompt": chosen["prompt"],
|
| 92 |
+
"chosen": chosen["text"], "rejected": rejected["text"],
|
| 93 |
+
"weight": 1.0, # DivPO is unweighted, by design
|
| 94 |
+
"chosen_quality": chosen["quality"], "chosen_dev": chosen["deviation"],
|
| 95 |
+
"rejected_quality": rejected["quality"], "rejected_dev": rejected["deviation"],
|
| 96 |
+
"chosen_meanlp": chosen["mean_logprob"], "rejected_meanlp": rejected["mean_logprob"],
|
| 97 |
+
})
|
| 98 |
+
skipped["ok"] += 1
|
| 99 |
+
|
| 100 |
+
n = len(pool)
|
| 101 |
+
stats = {"criterion": f"divpo-{criterion}", "rho": rho, "n_prompts": n,
|
| 102 |
+
"n_rows": len(rows), **skipped,
|
| 103 |
+
"skip_rate": 1 - skipped["ok"] / max(1, n)}
|
| 104 |
+
return rows, stats
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# --------------------------------------------------------------------- E3
|
| 108 |
+
def build_multipos(pool: dict[str, list[dict]], emb: np.ndarray, row_index: dict,
|
| 109 |
+
q_keep: float, k: int, lam: float) -> tuple[list[dict], dict]:
|
| 110 |
+
from diversity import greedy_diverse_subset
|
| 111 |
+
|
| 112 |
+
rows = []
|
| 113 |
+
stats = {"n_prompts": len(pool), "skipped_no_survivors": 0,
|
| 114 |
+
"skipped_no_negatives": 0, "ok": 0, "chosen_per_prompt": []}
|
| 115 |
+
|
| 116 |
+
for pid, items in pool.items():
|
| 117 |
+
survivors = [r for r in items if r["gate_passed"] and r["quality"] >= q_keep]
|
| 118 |
+
if len(survivors) < 2:
|
| 119 |
+
stats["skipped_no_survivors"] += 1; continue
|
| 120 |
+
|
| 121 |
+
# r_Q: clearly low quality (worst overall, gate failures included)
|
| 122 |
+
r_Q = min(items, key=lambda r: (r["gate_passed"], r["quality"]))
|
| 123 |
+
# r_D: the "competent cliche" -- high quality but LOW deviation.
|
| 124 |
+
# Rank by (quality - deviation) so we favour a strong, conventional
|
| 125 |
+
# story rather than merely the least diverse one.
|
| 126 |
+
r_D = max(survivors, key=lambda r: r["quality"] - 4.0 * r["deviation"])
|
| 127 |
+
|
| 128 |
+
# r_D is itself a survivor, so it can coincide with a chosen story; that
|
| 129 |
+
# specific pairing is dropped per-row below rather than here, since it
|
| 130 |
+
# only invalidates one row and not the whole prompt.
|
| 131 |
+
negatives = [n for n in (r_D, r_Q) if n["text"]]
|
| 132 |
+
if not negatives:
|
| 133 |
+
stats["skipped_no_negatives"] += 1; continue
|
| 134 |
+
|
| 135 |
+
idxs = [row_index[(pid, r["idx"])] for r in survivors]
|
| 136 |
+
E = emb[idxs].astype(np.float64)
|
| 137 |
+
q = np.array([r["quality"] for r in survivors], dtype=np.float64)
|
| 138 |
+
sel = greedy_diverse_subset(q, E, k=min(k, len(survivors)), lam=lam)
|
| 139 |
+
chosen_set = [survivors[i] for i in sel]
|
| 140 |
+
stats["chosen_per_prompt"].append(len(chosen_set))
|
| 141 |
+
|
| 142 |
+
for j, ch in enumerate(chosen_set):
|
| 143 |
+
neg = negatives[j % len(negatives)] # rotate, don't hammer one
|
| 144 |
+
if neg["text"] == ch["text"]:
|
| 145 |
+
continue
|
| 146 |
+
rows.append({
|
| 147 |
+
"prompt_id": pid, "prompt": ch["prompt"],
|
| 148 |
+
"chosen": ch["text"], "rejected": neg["text"],
|
| 149 |
+
"weight": float(ch["deviation"]), # DDPO-style loss weight
|
| 150 |
+
"neg_type": "r_D" if neg is r_D else "r_Q",
|
| 151 |
+
"chosen_quality": ch["quality"], "chosen_dev": ch["deviation"],
|
| 152 |
+
"rejected_quality": neg["quality"], "rejected_dev": neg["deviation"],
|
| 153 |
+
})
|
| 154 |
+
stats["ok"] += 1
|
| 155 |
+
|
| 156 |
+
stats["n_rows"] = len(rows)
|
| 157 |
+
stats["mean_chosen_per_prompt"] = float(np.mean(stats["chosen_per_prompt"])) if stats["chosen_per_prompt"] else 0.0
|
| 158 |
+
stats.pop("chosen_per_prompt")
|
| 159 |
+
stats["skip_rate"] = 1 - stats["ok"] / max(1, len(pool))
|
| 160 |
+
return rows, stats
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def normalize_weights(rows: list[dict]) -> None:
|
| 164 |
+
"""Scale weights to mean 1.0 so the DDPO weighting changes the RELATIVE
|
| 165 |
+
emphasis across rows without also rescaling the effective learning rate."""
|
| 166 |
+
w = np.array([r["weight"] for r in rows], dtype=np.float64)
|
| 167 |
+
if w.size and w.mean() > 1e-9:
|
| 168 |
+
w = w / w.mean()
|
| 169 |
+
for r, x in zip(rows, w):
|
| 170 |
+
r["weight"] = float(x)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def main():
|
| 174 |
+
ap = argparse.ArgumentParser()
|
| 175 |
+
ap.add_argument("--tag", default="4b")
|
| 176 |
+
ap.add_argument("--split", default="train")
|
| 177 |
+
ap.add_argument("--rho", type=float, default=6.0, help="DivPO quality threshold")
|
| 178 |
+
ap.add_argument("--q-keep", type=float, default=5.0, help="E3 survivor threshold")
|
| 179 |
+
ap.add_argument("--k", type=int, default=4, help="E3 chosen-subset size")
|
| 180 |
+
ap.add_argument("--lam", type=float, default=1.0, help="E3 logdet weight")
|
| 181 |
+
ap.add_argument("--max-skip", type=float, default=0.30)
|
| 182 |
+
args = ap.parse_args()
|
| 183 |
+
|
| 184 |
+
import logbook
|
| 185 |
+
|
| 186 |
+
pool = load_pool(args.tag, args.split)
|
| 187 |
+
emb = load_emb(args.tag, args.split)
|
| 188 |
+
row_index = {}
|
| 189 |
+
i = 0
|
| 190 |
+
for pid in pool:
|
| 191 |
+
for r in pool[pid]:
|
| 192 |
+
row_index[(pid, r["idx"])] = i; i += 1
|
| 193 |
+
assert i == emb.shape[0], f"pool/emb mismatch {i} vs {emb.shape[0]}"
|
| 194 |
+
|
| 195 |
+
out = ROOT / "outputs" / f"pairs_{args.tag}"
|
| 196 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 197 |
+
all_stats = {}
|
| 198 |
+
|
| 199 |
+
# ---- E4: DivPO, both criteria ---------------------------------------
|
| 200 |
+
for crit in ("emb", "prob"):
|
| 201 |
+
rho = args.rho
|
| 202 |
+
rows, st = build_divpo(pool, crit, rho)
|
| 203 |
+
if st["skip_rate"] > args.max_skip:
|
| 204 |
+
# One adjustment, as the brief allows, chosen by SWEEP rather than by
|
| 205 |
+
# a fixed percentile. Direction matters and is not knowable a priori:
|
| 206 |
+
# no_chosen dominant -> rho must come DOWN
|
| 207 |
+
# no_rejected dominant -> rho must go UP
|
| 208 |
+
# An earlier version always moved rho down (40th percentile), which
|
| 209 |
+
# is the wrong direction for this pool: the judge puts 88.8% of
|
| 210 |
+
# stories at >= 6, so the binding failure was no_rejected (311 of
|
| 211 |
+
# 1000 prompts had no story below the bar), not no_chosen (3).
|
| 212 |
+
# Sweeping the observed quality levels picks the threshold that
|
| 213 |
+
# actually maximizes usable prompts.
|
| 214 |
+
qs = sorted({r["quality"] for items in pool.values()
|
| 215 |
+
for r in items if r["gate_passed"]})
|
| 216 |
+
cands = [q for q in qs if 3.0 <= q <= 9.0] or [rho]
|
| 217 |
+
best = None
|
| 218 |
+
for c in cands:
|
| 219 |
+
_, s = build_divpo(pool, crit, float(c))
|
| 220 |
+
if best is None or s["skip_rate"] < best[1]["skip_rate"]:
|
| 221 |
+
best = (c, s)
|
| 222 |
+
rows2, st2 = build_divpo(pool, crit, float(best[0]))
|
| 223 |
+
st2["rho_adjusted_from"] = rho
|
| 224 |
+
st2["reason"] = (f"skip_rate {st['skip_rate']:.3f} > {args.max_skip} "
|
| 225 |
+
f"(no_chosen={st['no_chosen']}, "
|
| 226 |
+
f"no_rejected={st['no_rejected']}); swept "
|
| 227 |
+
f"{[float(c) for c in cands]} -> rho={best[0]}")
|
| 228 |
+
rows, st = rows2, st2
|
| 229 |
+
p = out / f"divpo_{crit}_{args.split}.jsonl"
|
| 230 |
+
with open(p, "w") as f:
|
| 231 |
+
for r in rows:
|
| 232 |
+
f.write(json.dumps(r) + "\n")
|
| 233 |
+
all_stats[f"divpo-{crit}"] = st
|
| 234 |
+
print(f"[divpo-{crit}] {json.dumps(st)}")
|
| 235 |
+
|
| 236 |
+
# ---- E3: multi-positive ---------------------------------------------
|
| 237 |
+
rows, st = build_multipos(pool, emb, row_index, args.q_keep, args.k, args.lam)
|
| 238 |
+
normalize_weights(rows)
|
| 239 |
+
st["weight_mean_after_norm"] = float(np.mean([r["weight"] for r in rows])) if rows else 0.0
|
| 240 |
+
st["weight_sd"] = float(np.std([r["weight"] for r in rows])) if rows else 0.0
|
| 241 |
+
from collections import Counter
|
| 242 |
+
st["neg_type_counts"] = dict(Counter(r["neg_type"] for r in rows))
|
| 243 |
+
p = out / f"multipos_{args.split}.jsonl"
|
| 244 |
+
with open(p, "w") as f:
|
| 245 |
+
for r in rows:
|
| 246 |
+
f.write(json.dumps(r) + "\n")
|
| 247 |
+
all_stats["e3-multipos"] = st
|
| 248 |
+
print(f"[e3-multipos] {json.dumps(st)}")
|
| 249 |
+
|
| 250 |
+
json.dump(all_stats, open(out / f"pair_stats_{args.split}.json", "w"), indent=2)
|
| 251 |
+
logbook.note(f"pairs built ({args.tag})",
|
| 252 |
+
f"```json\n{json.dumps(all_stats, indent=1)}\n```")
|
| 253 |
+
return 0
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
if __name__ == "__main__":
|
| 257 |
+
sys.exit(main())
|
src/build_pool.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Build the scored generation pool that feeds E3 (multi-positive diverse DPO) and
|
| 3 |
+
E4 (faithful DivPO), and doubles as base-model analysis data.
|
| 4 |
+
|
| 5 |
+
Per prompt: N=16 samples at temperature 1.0 from the BASE policy, each carrying
|
| 6 |
+
- text, token count, cumulative + length-normalized logprob (E4 divpo-prob)
|
| 7 |
+
- programmatic gate result
|
| 8 |
+
- judge quality / novelty (gate-passing stories only)
|
| 9 |
+
- embedding, per-group deviation d_i and marginal contribution m_i
|
| 10 |
+
|
| 11 |
+
Both DPO arms consume this identical artifact, which is the point: E4-vs-E3 is
|
| 12 |
+
then a comparison of PAIR CONSTRUCTION and LOSS, with the data held fixed.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import json
|
| 18 |
+
import sys
|
| 19 |
+
import time
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
|
| 24 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def main():
|
| 28 |
+
ap = argparse.ArgumentParser()
|
| 29 |
+
ap.add_argument("--model", default="Qwen/Qwen3-4B-Instruct-2507")
|
| 30 |
+
ap.add_argument("--tag", default="4b")
|
| 31 |
+
ap.add_argument("--n", type=int, default=16)
|
| 32 |
+
ap.add_argument("--temperature", type=float, default=1.0)
|
| 33 |
+
ap.add_argument("--top-p", type=float, default=1.0)
|
| 34 |
+
ap.add_argument("--limit", type=int, default=None, help="prompt subset, for smoke")
|
| 35 |
+
ap.add_argument("--split", default="train")
|
| 36 |
+
ap.add_argument("--seed", type=int, default=1234)
|
| 37 |
+
ap.add_argument("--gpu-mem", type=float, default=0.85)
|
| 38 |
+
args = ap.parse_args()
|
| 39 |
+
|
| 40 |
+
from transformers import AutoTokenizer
|
| 41 |
+
|
| 42 |
+
import gates
|
| 43 |
+
import logbook
|
| 44 |
+
from data import load_prompts
|
| 45 |
+
from diversity import (l2_normalize, logdet_volume, marginal_contributions,
|
| 46 |
+
pairwise_deviation)
|
| 47 |
+
from generate import build_llm, generate
|
| 48 |
+
from judge import build_judge
|
| 49 |
+
|
| 50 |
+
out_dir = ROOT / "outputs" / f"pool_{args.tag}"
|
| 51 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
pool_path = out_dir / f"pool_{args.split}.jsonl"
|
| 53 |
+
|
| 54 |
+
prompts = load_prompts(args.split, ROOT / "data")
|
| 55 |
+
if args.limit:
|
| 56 |
+
prompts = prompts[: args.limit]
|
| 57 |
+
print(f"[pool] {len(prompts)} prompts x N={args.n} = {len(prompts)*args.n} stories")
|
| 58 |
+
|
| 59 |
+
# ---- 1. generate -----------------------------------------------------
|
| 60 |
+
t0 = time.time()
|
| 61 |
+
tok = AutoTokenizer.from_pretrained(args.model)
|
| 62 |
+
llm = build_llm(args.model, gpu_mem_util=args.gpu_mem, seed=args.seed)
|
| 63 |
+
gens = generate(llm, tok, prompts, n=args.n, temperature=args.temperature,
|
| 64 |
+
top_p=args.top_p, seed=args.seed)
|
| 65 |
+
t_gen = time.time() - t0
|
| 66 |
+
ntok = sum(g.n_tokens for g in gens)
|
| 67 |
+
print(f"[gen] {len(gens)} stories, {ntok} tok in {t_gen/60:.1f} min "
|
| 68 |
+
f"({ntok/t_gen:.0f} tok/s)")
|
| 69 |
+
|
| 70 |
+
# free the GPU before loading the embedder
|
| 71 |
+
del llm
|
| 72 |
+
import gc, torch
|
| 73 |
+
gc.collect(); torch.cuda.empty_cache()
|
| 74 |
+
|
| 75 |
+
# ---- 2. gates --------------------------------------------------------
|
| 76 |
+
grs = [gates.check(g.text, finish_reason=g.finish_reason) for g in gens]
|
| 77 |
+
n_pass = sum(r.passed for r in grs)
|
| 78 |
+
print(f"[gates] pass {n_pass}/{len(grs)} ({100*n_pass/len(grs):.1f}%)")
|
| 79 |
+
|
| 80 |
+
# ---- 3. judge (gate-passers only) ------------------------------------
|
| 81 |
+
judge = build_judge(cache_path=str(ROOT / "cache" / "judge.sqlite"),
|
| 82 |
+
concurrency=24)
|
| 83 |
+
idx = [i for i in range(len(gens)) if grs[i].passed]
|
| 84 |
+
t1 = time.time()
|
| 85 |
+
scores = judge.score_many_sync([(gens[i].prompt, gens[i].text) for i in idx])
|
| 86 |
+
print(f"[judge] {len(idx)} scored in {(time.time()-t1)/60:.1f} min | "
|
| 87 |
+
f"health={judge.health()}")
|
| 88 |
+
judge.assert_healthy()
|
| 89 |
+
|
| 90 |
+
quality = np.zeros(len(gens)); novelty = np.zeros(len(gens))
|
| 91 |
+
for i, s in zip(idx, scores):
|
| 92 |
+
quality[i] = s.quality; novelty[i] = s.novelty
|
| 93 |
+
|
| 94 |
+
# ---- 4. embeddings + per-group diversity -----------------------------
|
| 95 |
+
from sentence_transformers import SentenceTransformer
|
| 96 |
+
enc = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cuda")
|
| 97 |
+
t2 = time.time()
|
| 98 |
+
E = enc.encode([g.text for g in gens], normalize_embeddings=True,
|
| 99 |
+
batch_size=64, show_progress_bar=False, convert_to_numpy=True)
|
| 100 |
+
E = l2_normalize(np.asarray(E, dtype=np.float64))
|
| 101 |
+
print(f"[embed] {E.shape} in {time.time()-t2:.0f}s")
|
| 102 |
+
|
| 103 |
+
by_prompt: dict[str, list[int]] = {}
|
| 104 |
+
for i, g in enumerate(gens):
|
| 105 |
+
by_prompt.setdefault(g.prompt_id, []).append(i)
|
| 106 |
+
|
| 107 |
+
dev = np.zeros(len(gens)); marg = np.zeros(len(gens))
|
| 108 |
+
group_logdet: dict[str, float] = {}
|
| 109 |
+
for pid, ids in by_prompt.items():
|
| 110 |
+
sub = E[ids]
|
| 111 |
+
d = pairwise_deviation(sub); m = marginal_contributions(sub)
|
| 112 |
+
for k, i in enumerate(ids):
|
| 113 |
+
dev[i] = d[k]; marg[i] = m[k]
|
| 114 |
+
group_logdet[pid] = logdet_volume(sub)
|
| 115 |
+
|
| 116 |
+
# ---- 5. write --------------------------------------------------------
|
| 117 |
+
with open(pool_path, "w") as f:
|
| 118 |
+
for i, g in enumerate(gens):
|
| 119 |
+
f.write(json.dumps({
|
| 120 |
+
**g.as_dict(),
|
| 121 |
+
"gate_passed": bool(grs[i].passed),
|
| 122 |
+
"gate_reasons": grs[i].reasons,
|
| 123 |
+
"ends_cleanly": grs[i].completeness,
|
| 124 |
+
"n_words": grs[i].n_words,
|
| 125 |
+
"quality": float(quality[i]),
|
| 126 |
+
"novelty": float(novelty[i]),
|
| 127 |
+
"deviation": float(dev[i]),
|
| 128 |
+
"marginal": float(marg[i]),
|
| 129 |
+
"group_logdet": float(group_logdet[g.prompt_id]),
|
| 130 |
+
}) + "\n")
|
| 131 |
+
np.save(out_dir / f"emb_{args.split}.npy", E.astype(np.float32))
|
| 132 |
+
|
| 133 |
+
# ---- 6. summary ------------------------------------------------------
|
| 134 |
+
q_pass = quality[[i for i in idx]]
|
| 135 |
+
summary = {
|
| 136 |
+
"tag": args.tag, "model": args.model, "split": args.split,
|
| 137 |
+
"n_prompts": len(prompts), "n_per_prompt": args.n, "n_stories": len(gens),
|
| 138 |
+
"temperature": args.temperature, "seed": args.seed,
|
| 139 |
+
"gate_pass_rate": float(n_pass / len(grs)),
|
| 140 |
+
"ends_cleanly_rate": float(np.mean([r.completeness for r in grs])),
|
| 141 |
+
"median_words": float(np.median([r.n_words for r in grs])),
|
| 142 |
+
"quality_mean": float(q_pass.mean()) if len(q_pass) else 0.0,
|
| 143 |
+
"quality_sd": float(q_pass.std()) if len(q_pass) else 0.0,
|
| 144 |
+
"novelty_mean": float(novelty[idx].mean()) if len(idx) else 0.0,
|
| 145 |
+
"deviation_mean": float(dev.mean()),
|
| 146 |
+
"logdet_mean": float(np.mean(list(group_logdet.values()))),
|
| 147 |
+
"gen_minutes": t_gen / 60,
|
| 148 |
+
"judge_cost": judge.cost_estimate(0.140, 0.280),
|
| 149 |
+
"judge_health": judge.health(),
|
| 150 |
+
}
|
| 151 |
+
json.dump(summary, open(out_dir / f"summary_{args.split}.json", "w"), indent=2)
|
| 152 |
+
print("\n" + json.dumps(summary, indent=1))
|
| 153 |
+
|
| 154 |
+
logbook.note(f"pool built: {args.tag}/{args.split}",
|
| 155 |
+
f"```json\n{json.dumps(summary, indent=1)}\n```")
|
| 156 |
+
logbook.checkpoint(f"pool_{args.tag}_{args.split}")
|
| 157 |
+
return 0
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
if __name__ == "__main__":
|
| 161 |
+
sys.exit(main())
|
src/calibrate_judge.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Judge calibration. A reward model you have not validated is a random number
|
| 3 |
+
generator with good manners, so this runs before any training.
|
| 4 |
+
|
| 5 |
+
Test set is built from material we already have, with KNOWN ground-truth
|
| 6 |
+
ordering, so we can measure discrimination rather than eyeball plausibility:
|
| 7 |
+
|
| 8 |
+
A. complete -- fresh Qwen3-4B stories that passed every programmatic gate
|
| 9 |
+
B. truncated -- prior-run stories, all cut off mid-word (known bad)
|
| 10 |
+
C. shuffled -- a complete story with its sentences randomly reordered
|
| 11 |
+
(destroys narrative coherence, preserves vocabulary/style)
|
| 12 |
+
D. repetitive -- a complete story with one paragraph repeated to fill it out
|
| 13 |
+
|
| 14 |
+
C and D matter most. Any judge can tell prose from garbage; the question is
|
| 15 |
+
whether it tracks COHERENCE, which is what quality is supposed to mean here. A
|
| 16 |
+
judge that scores C and D as highly as A is measuring surface fluency, and
|
| 17 |
+
would happily pay a policy to produce fluent incoherent text.
|
| 18 |
+
|
| 19 |
+
Pass criteria:
|
| 20 |
+
quality(A) - quality(B) >= 2.0 detects truncation
|
| 21 |
+
quality(A) - quality(C) >= 1.5 detects incoherence, not just fluency
|
| 22 |
+
quality(A) - quality(D) >= 1.5 detects padding/repetition
|
| 23 |
+
sd(quality within A) >= 0.7 not saturated/constant
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import glob
|
| 28 |
+
import json
|
| 29 |
+
import random
|
| 30 |
+
import re
|
| 31 |
+
import statistics
|
| 32 |
+
import sys
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
import gates
|
| 36 |
+
import logbook
|
| 37 |
+
from judge import build_judge
|
| 38 |
+
|
| 39 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 40 |
+
PRIOR = ROOT / "prior_run" / "eval_samples"
|
| 41 |
+
N_PER_CELL = 12
|
| 42 |
+
SEED = 11
|
| 43 |
+
|
| 44 |
+
PRICE_IN, PRICE_OUT = 0.140, 0.280 # deepseek-v4-flash-0731, $/1M tok
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def sentence_shuffle(text: str, rng: random.Random) -> str:
|
| 48 |
+
sents = re.split(r"(?<=[.!?])\s+", text.strip())
|
| 49 |
+
if len(sents) < 4:
|
| 50 |
+
return text
|
| 51 |
+
rng.shuffle(sents)
|
| 52 |
+
return " ".join(sents)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def hard_truncate(text: str, frac: float = 0.68) -> str:
|
| 56 |
+
"""Cut mid-word at `frac` of the way through -- the exact signature of a
|
| 57 |
+
max_new_tokens wall. Applied to cell A's OWN stories so the comparison
|
| 58 |
+
isolates truncation with style, voice and model held fixed."""
|
| 59 |
+
w = text.split()
|
| 60 |
+
cut = " ".join(w[: max(20, int(len(w) * frac))])
|
| 61 |
+
return cut[: max(1, len(cut) - 3)] # shave into the final word
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def paragraph_pad(text: str) -> str:
|
| 65 |
+
paras = [p for p in text.split("\n\n") if p.strip()]
|
| 66 |
+
if not paras:
|
| 67 |
+
return text
|
| 68 |
+
return "\n\n".join(paras[:2] + [paras[0]] * 3)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def main():
|
| 72 |
+
rng = random.Random(SEED)
|
| 73 |
+
from generate import load_gens
|
| 74 |
+
|
| 75 |
+
smoke = ROOT / "outputs" / "smoke" / "vllm_smoke.jsonl"
|
| 76 |
+
if not smoke.exists():
|
| 77 |
+
print("need outputs/smoke/vllm_smoke.jsonl (run smoke_vllm.py first)")
|
| 78 |
+
return 2
|
| 79 |
+
|
| 80 |
+
gens = load_gens(smoke)
|
| 81 |
+
good = [g for g in gens if gates.check(g.text, finish_reason=g.finish_reason).passed]
|
| 82 |
+
if len(good) < N_PER_CELL:
|
| 83 |
+
print(f"only {len(good)} gate-passing stories; need {N_PER_CELL}")
|
| 84 |
+
return 2
|
| 85 |
+
rng.shuffle(good)
|
| 86 |
+
A = good[:N_PER_CELL]
|
| 87 |
+
|
| 88 |
+
prior_texts = []
|
| 89 |
+
for f in sorted(glob.glob(str(PRIOR / "step_*.json"))):
|
| 90 |
+
for p, samples in json.load(open(f)).items():
|
| 91 |
+
for s in samples:
|
| 92 |
+
if not gates.ends_cleanly(s):
|
| 93 |
+
prior_texts.append((p.split("\n\n")[-1].strip(), s))
|
| 94 |
+
rng.shuffle(prior_texts)
|
| 95 |
+
B = prior_texts[:N_PER_CELL]
|
| 96 |
+
|
| 97 |
+
cells = {
|
| 98 |
+
"A_complete": [(g.prompt, g.text) for g in A],
|
| 99 |
+
"B_cut_matched": [(g.prompt, hard_truncate(g.text)) for g in A],
|
| 100 |
+
"C_shuffled": [(g.prompt, sentence_shuffle(g.text, rng)) for g in A],
|
| 101 |
+
"D_repetitive": [(g.prompt, paragraph_pad(g.text)) for g in A],
|
| 102 |
+
"E_cut_priorrun": B, # informational only: different policy AND style
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
judge = build_judge(cache_path=str(ROOT / "cache" / "judge.sqlite"), concurrency=8)
|
| 106 |
+
print(f"judge model: {judge.model}\n")
|
| 107 |
+
|
| 108 |
+
results = {}
|
| 109 |
+
for name, pairs in cells.items():
|
| 110 |
+
scores = judge.score_many_sync(pairs)
|
| 111 |
+
q = [s.quality for s in scores]
|
| 112 |
+
nv = [s.novelty for s in scores]
|
| 113 |
+
ok = sum(s.ok for s in scores)
|
| 114 |
+
results[name] = {
|
| 115 |
+
"cell": name, "n": len(q), "ok_calls": ok,
|
| 116 |
+
"quality_mean": statistics.fmean(q),
|
| 117 |
+
"quality_sd": statistics.pstdev(q) if len(q) > 1 else 0.0,
|
| 118 |
+
"quality_min": min(q), "quality_max": max(q),
|
| 119 |
+
"novelty_mean": statistics.fmean(nv),
|
| 120 |
+
"novelty_sd": statistics.pstdev(nv) if len(nv) > 1 else 0.0,
|
| 121 |
+
}
|
| 122 |
+
print(f"{name:14} q={results[name]['quality_mean']:.2f}"
|
| 123 |
+
f" (sd {results[name]['quality_sd']:.2f},"
|
| 124 |
+
f" {results[name]['quality_min']:.0f}-{results[name]['quality_max']:.0f})"
|
| 125 |
+
f" nov={results[name]['novelty_mean']:.2f}"
|
| 126 |
+
f" (sd {results[name]['novelty_sd']:.2f}) ok={ok}/{len(q)}")
|
| 127 |
+
|
| 128 |
+
qa = results["A_complete"]["quality_mean"]
|
| 129 |
+
# BLOCKING checks: these test properties no other component can supply.
|
| 130 |
+
checks = [
|
| 131 |
+
("detects incoherence A-C >= 1.5", qa - results["C_shuffled"]["quality_mean"], 1.5),
|
| 132 |
+
("detects repetition A-D >= 1.5", qa - results["D_repetitive"]["quality_mean"], 1.5),
|
| 133 |
+
("not saturated sd(A) >= 0.7", results["A_complete"]["quality_sd"], 0.7),
|
| 134 |
+
]
|
| 135 |
+
# INFORMATIONAL: truncation is already caught deterministically, with 100%
|
| 136 |
+
# recall, by gates.check() (finish_reason=="length" OR no terminal
|
| 137 |
+
# punctuation). RewardEngine.compute() judges ONLY gate-passing stories, so
|
| 138 |
+
# a truncated story never reaches the judge during training and the judge's
|
| 139 |
+
# truncation sensitivity cannot influence any gradient. Kept as a monitored
|
| 140 |
+
# number, not a gate on proceeding.
|
| 141 |
+
info = [
|
| 142 |
+
("detects truncation A-B (informational)",
|
| 143 |
+
qa - results["B_cut_matched"]["quality_mean"], 2.0),
|
| 144 |
+
("style confound A-E (informational)",
|
| 145 |
+
qa - results["E_cut_priorrun"]["quality_mean"], 0.0),
|
| 146 |
+
]
|
| 147 |
+
print("\n=== DISCRIMINATION (blocking) ===")
|
| 148 |
+
allpass = True
|
| 149 |
+
for label, val, thresh in checks:
|
| 150 |
+
ok = val >= thresh
|
| 151 |
+
allpass &= ok
|
| 152 |
+
print(f" {'PASS' if ok else 'FAIL'} {label:34} got {val:+.2f}")
|
| 153 |
+
print("=== informational (not blocking) ===")
|
| 154 |
+
for label, val, thresh in info:
|
| 155 |
+
print(f" {'ok ' if val >= thresh else 'note'} {label:40} got {val:+.2f}")
|
| 156 |
+
checks = checks + info
|
| 157 |
+
|
| 158 |
+
cost = judge.cost_estimate(PRICE_IN, PRICE_OUT)
|
| 159 |
+
health = judge.health()
|
| 160 |
+
print(f"\njudge health: {health}")
|
| 161 |
+
print(f"cost so far: {cost}")
|
| 162 |
+
if health["fail_rate"] > 0.05:
|
| 163 |
+
print("\n!!! JUDGE UNHEALTHY -- scores above are mostly neutral fallbacks, "
|
| 164 |
+
"discrimination numbers are meaningless. Fix the backend first.")
|
| 165 |
+
allpass = False
|
| 166 |
+
|
| 167 |
+
body = f"""# Judge calibration — `{judge.model}`
|
| 168 |
+
|
| 169 |
+
A reward model that has not been validated is a random number generator with
|
| 170 |
+
good manners. This is the pre-flight check, run before any training.
|
| 171 |
+
|
| 172 |
+
## Method
|
| 173 |
+
|
| 174 |
+
Four cells, 12 stories each, with known ground-truth ordering:
|
| 175 |
+
|
| 176 |
+
- **A_complete** — fresh Qwen3-4B stories passing every programmatic gate.
|
| 177 |
+
- **B_cut_matched** — cell A's OWN stories, cut mid-word at 68% length. This is
|
| 178 |
+
the controlled truncation test: same model, same voice, same prompt, so the
|
| 179 |
+
only variable is finishedness.
|
| 180 |
+
- **E_cut_priorrun** — prior-run stories, also truncated. Informational only:
|
| 181 |
+
they come from a different (RL-tuned, far more florid) policy, so A-vs-E
|
| 182 |
+
confounds truncation with style and cannot be read as a truncation test.
|
| 183 |
+
- **C_shuffled** — cell A with sentences randomly reordered. Destroys narrative
|
| 184 |
+
coherence while preserving vocabulary, register and sentence-level fluency.
|
| 185 |
+
- **D_repetitive** — cell A with one paragraph repeated to pad the length.
|
| 186 |
+
|
| 187 |
+
C and D carry the weight. Any judge separates prose from garbage; the real
|
| 188 |
+
question is whether it tracks *coherence* rather than surface fluency. A judge
|
| 189 |
+
that rates C as highly as A would pay a policy to emit fluent incoherent text —
|
| 190 |
+
exactly the reward-hacking channel the diversity bonus could exploit.
|
| 191 |
+
|
| 192 |
+
## Results
|
| 193 |
+
|
| 194 |
+
{logbook.table(list(results.values()))}
|
| 195 |
+
|
| 196 |
+
## Discrimination checks
|
| 197 |
+
|
| 198 |
+
{logbook.table([{"check": l, "value": round(v, 3), "threshold": t,
|
| 199 |
+
"verdict": "PASS" if v >= t else "FAIL"} for l, v, t in checks])}
|
| 200 |
+
|
| 201 |
+
**Verdict: {"USABLE" if allpass else "NOT USABLE AS-IS"}**
|
| 202 |
+
|
| 203 |
+
## Judge health
|
| 204 |
+
|
| 205 |
+
```json
|
| 206 |
+
{json.dumps(health, indent=1)}
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
## Cost accounting
|
| 210 |
+
|
| 211 |
+
```json
|
| 212 |
+
{json.dumps(cost, indent=1)}
|
| 213 |
+
```
|
| 214 |
+
"""
|
| 215 |
+
p = logbook.write_report("01_judge_calibration", body)
|
| 216 |
+
print("report ->", p)
|
| 217 |
+
json.dump({"results": results,
|
| 218 |
+
"checks": [{"c": l, "v": v, "t": t} for l, v, t in checks],
|
| 219 |
+
"cost": cost, "model": judge.model},
|
| 220 |
+
open(logbook.LOGS / "judge_calibration.json", "w"), indent=2)
|
| 221 |
+
logbook.note("judge calibration",
|
| 222 |
+
f"model={judge.model} verdict={'USABLE' if allpass else 'FAIL'}; "
|
| 223 |
+
f"A={qa:.2f} B={results['B_cut_matched']['quality_mean']:.2f} "
|
| 224 |
+
f"C={results['C_shuffled']['quality_mean']:.2f} "
|
| 225 |
+
f"D={results['D_repetitive']['quality_mean']:.2f}")
|
| 226 |
+
return 0 if allpass else 1
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
if __name__ == "__main__":
|
| 230 |
+
sys.exit(main())
|
src/ckpt_study.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Checkpoint trajectory study: generate the SAME prompts from every checkpoint of
|
| 3 |
+
an arm, so the evolution of the actual stories is visible -- not just metrics.
|
| 4 |
+
|
| 5 |
+
Aggregate numbers can report "effective rank 2.0" without conveying that six of
|
| 6 |
+
sixteen ships are named *Aethel*. This dumps the stories in a readable form at
|
| 7 |
+
each training step alongside the metrics, so the qualitative change can be read
|
| 8 |
+
directly and cross-checked against the quantitative one.
|
| 9 |
+
|
| 10 |
+
vLLM loads the base model ONCE and hot-swaps LoRA adapters per checkpoint, so
|
| 11 |
+
the whole sweep costs one model load rather than one per checkpoint.
|
| 12 |
+
|
| 13 |
+
Outputs (under outputs/ckpt_study/<arm>/):
|
| 14 |
+
stories.md human-readable: every prompt, every checkpoint, side by side
|
| 15 |
+
metrics.csv per-checkpoint quantitative trajectory
|
| 16 |
+
raw.json everything, for re-analysis
|
| 17 |
+
../logs/figures/<arm>_trajectory.png
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import csv
|
| 23 |
+
import json
|
| 24 |
+
import re
|
| 25 |
+
import sys
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
|
| 30 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def find_checkpoints(arm: str) -> list[tuple[int, str | None]]:
|
| 34 |
+
"""[(step, adapter_path_or_None)] ascending; step 0 = base model."""
|
| 35 |
+
d = ROOT / "outputs" / arm
|
| 36 |
+
out: list[tuple[int, str | None]] = [(0, None)]
|
| 37 |
+
if d.exists():
|
| 38 |
+
for p in d.glob("checkpoint-*"):
|
| 39 |
+
m = re.search(r"checkpoint-(\d+)", p.name)
|
| 40 |
+
if m and (p / "adapter_model.safetensors").exists():
|
| 41 |
+
out.append((int(m.group(1)), str(p)))
|
| 42 |
+
f = d / "final"
|
| 43 |
+
if (f / "adapter_model.safetensors").exists():
|
| 44 |
+
steps = [s for s, _ in out]
|
| 45 |
+
out.append((max(steps) + 1 if steps else 1, str(f)))
|
| 46 |
+
return sorted(out, key=lambda t: t[0])
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def main():
|
| 50 |
+
ap = argparse.ArgumentParser()
|
| 51 |
+
ap.add_argument("--arm", required=True)
|
| 52 |
+
ap.add_argument("--model", default="Qwen/Qwen3-4B-Instruct-2507")
|
| 53 |
+
ap.add_argument("--prompts", type=int, default=10)
|
| 54 |
+
ap.add_argument("--n", type=int, default=6)
|
| 55 |
+
ap.add_argument("--temp", type=float, default=0.9)
|
| 56 |
+
ap.add_argument("--top-p", type=float, default=0.95)
|
| 57 |
+
ap.add_argument("--seed", type=int, default=777)
|
| 58 |
+
ap.add_argument("--gpu-mem", type=float, default=0.85)
|
| 59 |
+
ap.add_argument("--judge", action="store_true", default=True)
|
| 60 |
+
args = ap.parse_args()
|
| 61 |
+
|
| 62 |
+
from transformers import AutoTokenizer
|
| 63 |
+
from vllm import SamplingParams
|
| 64 |
+
from vllm.lora.request import LoRARequest
|
| 65 |
+
|
| 66 |
+
import gates
|
| 67 |
+
import logbook
|
| 68 |
+
from data import load_prompts
|
| 69 |
+
from diversity import effective_rank, l2_normalize, logdet_volume, pairwise_deviation
|
| 70 |
+
from generate import build_llm, render_chat
|
| 71 |
+
from judge import build_judge
|
| 72 |
+
from qualitative import analyze_group, first_sentence, last_sentence
|
| 73 |
+
|
| 74 |
+
ckpts = find_checkpoints(args.arm)
|
| 75 |
+
if len(ckpts) < 2:
|
| 76 |
+
print(f"only {len(ckpts)} checkpoint(s) for {args.arm}; nothing to compare")
|
| 77 |
+
return 2
|
| 78 |
+
print(f"[{args.arm}] checkpoints: {[s for s, _ in ckpts]}")
|
| 79 |
+
|
| 80 |
+
prompts = load_prompts("eval", ROOT / "data")[: args.prompts]
|
| 81 |
+
tok = AutoTokenizer.from_pretrained(args.model)
|
| 82 |
+
llm = build_llm(args.model, gpu_mem_util=args.gpu_mem, seed=args.seed,
|
| 83 |
+
enable_lora=True)
|
| 84 |
+
rendered = [render_chat(tok, p["prompt"]) for p in prompts]
|
| 85 |
+
sp = SamplingParams(n=args.n, temperature=args.temp, top_p=args.top_p,
|
| 86 |
+
max_tokens=1024, seed=args.seed, skip_special_tokens=True)
|
| 87 |
+
|
| 88 |
+
from sentence_transformers import SentenceTransformer
|
| 89 |
+
enc = None
|
| 90 |
+
judge = build_judge(cache_path=str(ROOT / "cache" / "judge.sqlite"), concurrency=24) \
|
| 91 |
+
if args.judge else None
|
| 92 |
+
|
| 93 |
+
all_data, rowsum = {}, []
|
| 94 |
+
for step, path in ckpts:
|
| 95 |
+
kw = {}
|
| 96 |
+
if path:
|
| 97 |
+
kw["lora_request"] = LoRARequest(f"{args.arm}-{step}", max(step, 1), path)
|
| 98 |
+
outs = llm.generate(rendered, sp, **kw)
|
| 99 |
+
per = {}
|
| 100 |
+
for p, o in zip(prompts, outs):
|
| 101 |
+
texts = [x.text.strip() for x in o.outputs]
|
| 102 |
+
frs = [x.finish_reason or "" for x in o.outputs]
|
| 103 |
+
per[p["id"]] = {"prompt": p["prompt"], "texts": texts,
|
| 104 |
+
"gates": [gates.check(t, finish_reason=f).as_dict()
|
| 105 |
+
for t, f in zip(texts, frs)]}
|
| 106 |
+
all_data[step] = per
|
| 107 |
+
print(f" step {step:>4}: generated {sum(len(v['texts']) for v in per.values())} stories",
|
| 108 |
+
flush=True)
|
| 109 |
+
|
| 110 |
+
del llm
|
| 111 |
+
import gc, torch
|
| 112 |
+
gc.collect(); torch.cuda.empty_cache()
|
| 113 |
+
enc = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cuda")
|
| 114 |
+
|
| 115 |
+
for step, per in all_data.items():
|
| 116 |
+
dev, ld, er, q = [], [], [], []
|
| 117 |
+
pooled_texts = []
|
| 118 |
+
for pid, v in per.items():
|
| 119 |
+
E = l2_normalize(np.asarray(enc.encode(
|
| 120 |
+
v["texts"], normalize_embeddings=True, show_progress_bar=False,
|
| 121 |
+
convert_to_numpy=True), dtype=np.float64))
|
| 122 |
+
v["eff_rank"] = float(effective_rank(E))
|
| 123 |
+
v["deviation"] = float(pairwise_deviation(E).mean())
|
| 124 |
+
v["logdet"] = float(logdet_volume(E))
|
| 125 |
+
v["qual"] = analyze_group(v["texts"])
|
| 126 |
+
dev.append(v["deviation"]); ld.append(v["logdet"]); er.append(v["eff_rank"])
|
| 127 |
+
pooled_texts += [(v["prompt"], t, g["passed"])
|
| 128 |
+
for t, g in zip(v["texts"], v["gates"])]
|
| 129 |
+
if judge:
|
| 130 |
+
idx = [i for i, (_, _, ok) in enumerate(pooled_texts) if ok]
|
| 131 |
+
sc = judge.score_many_sync([(pooled_texts[i][0], pooled_texts[i][1]) for i in idx])
|
| 132 |
+
q = [s.quality for s in sc]
|
| 133 |
+
gp = float(np.mean([g["passed"] for v in per.values() for g in v["gates"]]))
|
| 134 |
+
ec = float(np.mean([g["completeness"] for v in per.values() for g in v["gates"]]))
|
| 135 |
+
wd = float(np.mean([g["n_words"] for v in per.values() for g in v["gates"]]))
|
| 136 |
+
rowsum.append({
|
| 137 |
+
"step": step, "quality": float(np.mean(q)) if q else 0.0,
|
| 138 |
+
"eff_rank": float(np.mean(er)), "deviation": float(np.mean(dev)),
|
| 139 |
+
"logdet": float(np.mean(ld)), "gate_pass": gp, "ends_cleanly": ec,
|
| 140 |
+
"words": wd,
|
| 141 |
+
"opens_with_The": float(np.mean([v["qual"]["opens_with_The"] / v["qual"]["n"]
|
| 142 |
+
for v in per.values()])),
|
| 143 |
+
"distinct_openers": float(np.mean([v["qual"]["distinct_first_5_words"] / v["qual"]["n"]
|
| 144 |
+
for v in per.values()])),
|
| 145 |
+
"registers": float(np.mean([v["qual"]["registers_present"] for v in per.values()])),
|
| 146 |
+
})
|
| 147 |
+
print(f" step {step:>4}: q={rowsum[-1]['quality']:.2f} "
|
| 148 |
+
f"eff_rank={rowsum[-1]['eff_rank']:.3f} dev={rowsum[-1]['deviation']:.4f} "
|
| 149 |
+
f"words={wd:.0f}", flush=True)
|
| 150 |
+
|
| 151 |
+
out = ROOT / "outputs" / "ckpt_study" / args.arm
|
| 152 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 153 |
+
with open(out / "metrics.csv", "w", newline="") as f:
|
| 154 |
+
w = csv.DictWriter(f, fieldnames=list(rowsum[0].keys()))
|
| 155 |
+
w.writeheader(); w.writerows(rowsum)
|
| 156 |
+
json.dump(all_data, open(out / "raw.json", "w"), indent=1)
|
| 157 |
+
|
| 158 |
+
# ---- human-readable side-by-side --------------------------------------
|
| 159 |
+
steps = [s for s, _ in ckpts]
|
| 160 |
+
md = [f"# {args.arm} — story trajectory across checkpoints\n",
|
| 161 |
+
f"{len(prompts)} eval prompts x {args.n} samples, T={args.temp}, "
|
| 162 |
+
f"top_p={args.top_p}, seed={args.seed} (fixed across checkpoints).\n",
|
| 163 |
+
"Step 0 = base model.\n"]
|
| 164 |
+
for pid in list(all_data[steps[0]]):
|
| 165 |
+
md.append(f"\n## {pid}\n\n> {all_data[steps[0]][pid]['prompt']}\n")
|
| 166 |
+
for s in steps:
|
| 167 |
+
v = all_data[s][pid]
|
| 168 |
+
md.append(f"\n### step {s} — eff_rank {v['eff_rank']:.2f}, "
|
| 169 |
+
f"dev {v['deviation']:.3f}\n")
|
| 170 |
+
md.append("\n**openings**\n")
|
| 171 |
+
for i, t in enumerate(v["texts"]):
|
| 172 |
+
md.append(f"{i+1}. {first_sentence(t, 150)}\n")
|
| 173 |
+
md.append("\n**closings**\n")
|
| 174 |
+
for i, t in enumerate(v["texts"]):
|
| 175 |
+
md.append(f"{i+1}. …{last_sentence(t, 110)}\n")
|
| 176 |
+
(out / "stories.md").write_text("".join(md))
|
| 177 |
+
|
| 178 |
+
# ---- figure -----------------------------------------------------------
|
| 179 |
+
import matplotlib; matplotlib.use("Agg")
|
| 180 |
+
import matplotlib.pyplot as plt
|
| 181 |
+
x = [r["step"] for r in rowsum]
|
| 182 |
+
fig, ax = plt.subplots(1, 4, figsize=(19, 4.2))
|
| 183 |
+
# Anchor the axes that have a meaningful absolute scale. Auto-scaling a
|
| 184 |
+
# metric that moved 1.60->1.69 against a ceiling of N renders a dramatic
|
| 185 |
+
# line for a flat result, which is exactly the misreading to avoid.
|
| 186 |
+
for a, (k, t, c) in zip(ax, [("quality", "Judge quality (0-10)", "#2980b9"),
|
| 187 |
+
("eff_rank", "Effective rank (1 = collapsed, %d = max)" % args.n, "#8e44ad"),
|
| 188 |
+
("deviation", "Mean pairwise deviation", "#16a085"),
|
| 189 |
+
("words", "Story length (words)", "#7f8c8d")]):
|
| 190 |
+
vals = [r[k] for r in rowsum]
|
| 191 |
+
a.plot(x, vals, "o-", color=c, lw=2)
|
| 192 |
+
if k == "eff_rank":
|
| 193 |
+
a.set_ylim(1.0, args.n) # full meaningful range
|
| 194 |
+
a.axhline(1.0, ls=":", c="crimson", lw=1)
|
| 195 |
+
a.text(x[0], 1.05, "total collapse", fontsize=7, color="crimson")
|
| 196 |
+
elif k == "quality":
|
| 197 |
+
a.set_ylim(0, 10)
|
| 198 |
+
elif k == "deviation":
|
| 199 |
+
a.set_ylim(0, max(0.5, max(vals) * 1.3))
|
| 200 |
+
a.set_title(t, fontsize=10); a.set_xlabel("training step"); a.grid(alpha=.3)
|
| 201 |
+
fig.suptitle(f"{args.arm}: what happens to the stories during training", fontsize=13)
|
| 202 |
+
plt.tight_layout()
|
| 203 |
+
figp = logbook.FIGS / f"{args.arm}_trajectory.png"
|
| 204 |
+
plt.savefig(figp, dpi=140); plt.close()
|
| 205 |
+
|
| 206 |
+
print(f"\nstories -> {out/'stories.md'}")
|
| 207 |
+
print(f"metrics -> {out/'metrics.csv'}")
|
| 208 |
+
print(f"figure -> {figp}")
|
| 209 |
+
if judge:
|
| 210 |
+
print("judge:", judge.health(), judge.cost_estimate(0.140, 0.280))
|
| 211 |
+
return 0
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
if __name__ == "__main__":
|
| 215 |
+
sys.exit(main())
|
src/data.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WritingPrompts data prep. Deterministic and seeded: every arm sees the exact
|
| 3 |
+
same train split and the exact same 50 held-out eval prompts.
|
| 4 |
+
|
| 5 |
+
On the SYSTEM PROMPT (rewritten from the prior run)
|
| 6 |
+
---------------------------------------------------
|
| 7 |
+
The prior run used, with a ~512-token cap and no length guidance:
|
| 8 |
+
|
| 9 |
+
"Write a short creative story based on this prompt:\\n\\n{text}"
|
| 10 |
+
|
| 11 |
+
Result: 100% of eval samples truncated mid-word. The model had no budget to
|
| 12 |
+
plan an ending, so it never planned one. Since the judge rubric caps quality at
|
| 13 |
+
<=3 for unfinished stories, quality became near-constant within every group ->
|
| 14 |
+
zero GRPO advantage -> the quality signal was dead for the whole run.
|
| 15 |
+
|
| 16 |
+
The fix is a word budget + an explicit ending instruction + a raised token cap.
|
| 17 |
+
|
| 18 |
+
What the system prompt deliberately does NOT do: it says nothing about being
|
| 19 |
+
original, varied, surprising, or avoiding cliche. That would be a confound.
|
| 20 |
+
Mode collapse is the dependent variable; instructing the model to diversify
|
| 21 |
+
would mask exactly the effect every arm is being measured on. The prompt is
|
| 22 |
+
identical and neutral for base, E0, E1, E2, E3 and E4 -- all differences
|
| 23 |
+
between arms must come from the training objective, not the prompt.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import hashlib
|
| 29 |
+
import json
|
| 30 |
+
import re
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
|
| 33 |
+
from datasets import load_dataset
|
| 34 |
+
|
| 35 |
+
SYSTEM_PROMPT = (
|
| 36 |
+
"You are a fiction writer. Write a complete short story of 200-500 words "
|
| 37 |
+
"responding to the writing prompt.\n"
|
| 38 |
+
"Write only the story: no title, no preamble, no commentary, no author's note.\n"
|
| 39 |
+
"Finish inside the word budget. The story must reach a real ending, not stop mid-scene."
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
MIN_PROMPT_WORDS = 10
|
| 43 |
+
MAX_PROMPT_WORDS = 60
|
| 44 |
+
SEED = 42
|
| 45 |
+
|
| 46 |
+
_TAG = re.compile(r"^\s*\[\s*(WP|EU|CW|TT|RF|IP|PI|PM|MP|OT|SP|FF)\s*\]\s*", re.I)
|
| 47 |
+
_WS = re.compile(r"\s+")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def clean_prompt(text: str) -> str:
|
| 51 |
+
"""Strip the subreddit tag and normalize the mangled WritingPrompts spacing.
|
| 52 |
+
|
| 53 |
+
The euclaise mirror is tokenizer-detokenized: "did n't phase us ." etc.
|
| 54 |
+
Left as-is this leaks a distinctive artifact into every prompt and wastes
|
| 55 |
+
judge tokens, so we repair the obvious cases.
|
| 56 |
+
"""
|
| 57 |
+
t = _TAG.sub("", text or "").strip()
|
| 58 |
+
t = t.replace("`` ", '"').replace(" ''", '"').replace("''", '"')
|
| 59 |
+
t = re.sub(r"([“‘(\[])\s+", r"\1", t) # "“ What" -> "“What"
|
| 60 |
+
t = re.sub(r"\s+([”’)\]])", r"\1", t) # "cockroach! ”" -> "cockroach!”"
|
| 61 |
+
t = re.sub(r"\s+([,.!?;:])", r"\1", t)
|
| 62 |
+
t = re.sub(r"\bn't\b", "n't", t)
|
| 63 |
+
t = re.sub(r"\s+n't", "n't", t)
|
| 64 |
+
t = re.sub(r"\s+'(s|re|ve|ll|d|m)\b", r"'\1", t)
|
| 65 |
+
t = re.sub(r"<\s*newline\s*>", " ", t, flags=re.I)
|
| 66 |
+
t = _WS.sub(" ", t).strip()
|
| 67 |
+
return t
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _key(t: str) -> str:
|
| 71 |
+
"""Dedupe key: lowercase alphanumerics only, so punctuation/spacing
|
| 72 |
+
variants of the same prompt collapse together."""
|
| 73 |
+
return hashlib.sha1(re.sub(r"[^a-z0-9]", "", t.lower()).encode()).hexdigest()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def build_splits(
|
| 77 |
+
n_train: int = 1000,
|
| 78 |
+
n_eval: int = 50,
|
| 79 |
+
out_dir: str | Path = "data",
|
| 80 |
+
seed: int = SEED,
|
| 81 |
+
) -> dict:
|
| 82 |
+
ds = load_dataset("euclaise/writingprompts", split="train")
|
| 83 |
+
col = "prompt" if "prompt" in ds.column_names else ds.column_names[0]
|
| 84 |
+
|
| 85 |
+
seen: set[str] = set()
|
| 86 |
+
kept: list[str] = []
|
| 87 |
+
for rec in ds:
|
| 88 |
+
t = clean_prompt(rec[col])
|
| 89 |
+
n = len(t.split())
|
| 90 |
+
if not (MIN_PROMPT_WORDS <= n <= MAX_PROMPT_WORDS):
|
| 91 |
+
continue
|
| 92 |
+
k = _key(t)
|
| 93 |
+
if k in seen:
|
| 94 |
+
continue
|
| 95 |
+
seen.add(k)
|
| 96 |
+
kept.append(t)
|
| 97 |
+
if len(kept) >= (n_train + n_eval) * 3: # oversample, then shuffle
|
| 98 |
+
break
|
| 99 |
+
|
| 100 |
+
import random
|
| 101 |
+
random.Random(seed).shuffle(kept)
|
| 102 |
+
need = n_train + n_eval
|
| 103 |
+
if len(kept) < need:
|
| 104 |
+
raise RuntimeError(f"only {len(kept)} usable prompts, need {need}")
|
| 105 |
+
sel = kept[:need]
|
| 106 |
+
eval_prompts, train_prompts = sel[:n_eval], sel[n_eval:need]
|
| 107 |
+
|
| 108 |
+
out = Path(out_dir)
|
| 109 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 110 |
+
for name, rows in (("train", train_prompts), ("eval", eval_prompts)):
|
| 111 |
+
p = out / f"{name}_prompts.jsonl"
|
| 112 |
+
with open(p, "w") as f:
|
| 113 |
+
for i, t in enumerate(rows):
|
| 114 |
+
f.write(json.dumps({"id": f"{name}-{i:04d}", "prompt": t}) + "\n")
|
| 115 |
+
|
| 116 |
+
meta = {
|
| 117 |
+
"seed": seed, "n_train": len(train_prompts), "n_eval": len(eval_prompts),
|
| 118 |
+
"total_scanned": len(ds), "usable_after_filter": len(kept),
|
| 119 |
+
"min_words": MIN_PROMPT_WORDS, "max_words": MAX_PROMPT_WORDS,
|
| 120 |
+
"system_prompt": SYSTEM_PROMPT,
|
| 121 |
+
}
|
| 122 |
+
(out / "split_meta.json").write_text(json.dumps(meta, indent=2))
|
| 123 |
+
return meta
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def load_prompts(split: str, out_dir: str | Path = "data") -> list[dict]:
|
| 127 |
+
p = Path(out_dir) / f"{split}_prompts.jsonl"
|
| 128 |
+
return [json.loads(l) for l in open(p) if l.strip()]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def chat_messages(prompt: str) -> list[dict]:
|
| 132 |
+
return [
|
| 133 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 134 |
+
{"role": "user", "content": prompt},
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
m = build_splits()
|
| 140 |
+
print(json.dumps(m, indent=2))
|
| 141 |
+
for split in ("train", "eval"):
|
| 142 |
+
rows = load_prompts(split)
|
| 143 |
+
print(f"\n{split}: {len(rows)}")
|
| 144 |
+
for r in rows[:3]:
|
| 145 |
+
print(" ", r["id"], "|", r["prompt"][:110])
|
src/diversity.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Diversity math over an embedding group. Pure numpy, no torch.
|
| 3 |
+
|
| 4 |
+
Given L2-normalized embeddings E = [e_1..e_G] (shape G x d) for ONE prompt's
|
| 5 |
+
generation group, we expose two families of per-sample diversity credit:
|
| 6 |
+
|
| 7 |
+
1. deviation d_i = mean_{j!=i} (1 - cos(e_i, e_j))
|
| 8 |
+
-- pairwise, cheap, but blind to cluster structure:
|
| 9 |
+
two tight far-apart clusters score as high as a spread.
|
| 10 |
+
|
| 11 |
+
2. marginal m_i = logdet(L) - logdet(L_{-i})
|
| 12 |
+
contribution -- leave-one-out volume credit under the cosine kernel.
|
| 13 |
+
A duplicate contributes ~nothing (its direction is already
|
| 14 |
+
spanned), so this DOES see cluster structure.
|
| 15 |
+
|
| 16 |
+
Both are per-sample. This is deliberate: a set-level scalar (e.g. logdet(L)
|
| 17 |
+
itself) is constant within a GRPO group, so its within-group std is 0 and the
|
| 18 |
+
normalized advantage is identically 0. It cannot train anything. Every
|
| 19 |
+
quantity here varies across i within a group.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
|
| 26 |
+
EPS_JITTER = 1e-3
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def l2_normalize(E: np.ndarray, axis: int = -1) -> np.ndarray:
|
| 30 |
+
"""Row-normalize; zero rows are left as zeros rather than NaN."""
|
| 31 |
+
E = np.asarray(E, dtype=np.float64)
|
| 32 |
+
n = np.linalg.norm(E, axis=axis, keepdims=True)
|
| 33 |
+
return E / np.maximum(n, 1e-12)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def cosine_kernel(E: np.ndarray, eps: float = EPS_JITTER) -> np.ndarray:
|
| 37 |
+
"""L = E E^T + eps*I.
|
| 38 |
+
|
| 39 |
+
E must be L2-normalized, so diag(E E^T) = 1 and L is a correlation-like
|
| 40 |
+
PSD matrix. The eps jitter keeps logdet finite when rows are collinear
|
| 41 |
+
(exact duplicates make E E^T singular).
|
| 42 |
+
"""
|
| 43 |
+
E = np.asarray(E, dtype=np.float64)
|
| 44 |
+
L = E @ E.T
|
| 45 |
+
L = 0.5 * (L + L.T) # kill float asymmetry before Cholesky
|
| 46 |
+
return L + eps * np.eye(L.shape[0], dtype=np.float64)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def pairwise_deviation(E: np.ndarray) -> np.ndarray:
|
| 50 |
+
"""d_i = mean_{j != i} (1 - cos(e_i, e_j)). Shape (G,).
|
| 51 |
+
|
| 52 |
+
G == 1 has no off-diagonal terms; we return 0.0 (a lone sample has no
|
| 53 |
+
measurable deviation, and 0 is the neutral value for the reward).
|
| 54 |
+
"""
|
| 55 |
+
E = np.asarray(E, dtype=np.float64)
|
| 56 |
+
G = E.shape[0]
|
| 57 |
+
if G < 2:
|
| 58 |
+
return np.zeros(G, dtype=np.float64)
|
| 59 |
+
S = E @ E.T
|
| 60 |
+
D = 1.0 - S
|
| 61 |
+
np.fill_diagonal(D, 0.0)
|
| 62 |
+
return D.sum(axis=1) / (G - 1)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _logdet_psd(L: np.ndarray) -> float:
|
| 66 |
+
"""logdet via Cholesky; falls back to slogdet if L drifts non-PD."""
|
| 67 |
+
try:
|
| 68 |
+
c = np.linalg.cholesky(L)
|
| 69 |
+
return float(2.0 * np.sum(np.log(np.diag(c))))
|
| 70 |
+
except np.linalg.LinAlgError:
|
| 71 |
+
sign, ld = np.linalg.slogdet(L)
|
| 72 |
+
if sign <= 0:
|
| 73 |
+
return float("-inf")
|
| 74 |
+
return float(ld)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def logdet_volume(E: np.ndarray, eps: float = EPS_JITTER) -> float:
|
| 78 |
+
"""D(Y) = logdet(E E^T + eps I). Set-level scalar.
|
| 79 |
+
|
| 80 |
+
Report this as a METRIC. Never hand it to GRPO as a per-sample reward:
|
| 81 |
+
it is identical for every i in the group -> zero advantage.
|
| 82 |
+
"""
|
| 83 |
+
if np.asarray(E).shape[0] == 0:
|
| 84 |
+
return 0.0
|
| 85 |
+
return _logdet_psd(cosine_kernel(E, eps))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def marginal_contributions(E: np.ndarray, eps: float = EPS_JITTER) -> np.ndarray:
|
| 89 |
+
"""m_i = logdet(L) - logdet(L_{-i}), shape (G,).
|
| 90 |
+
|
| 91 |
+
Computed by G explicit leave-one-out logdets. G <= 16 here, so this is
|
| 92 |
+
~16 Cholesky calls on a 15x15 matrix -- utterly negligible next to a
|
| 93 |
+
single LLM forward pass. Rank-one downdate machinery would be a
|
| 94 |
+
micro-optimization with real numerical-stability downside; not worth it.
|
| 95 |
+
|
| 96 |
+
Interpretation: m_i is the log-volume the group loses by dropping i.
|
| 97 |
+
A duplicate has m_i ~ log(eps) -> large negative. An orthogonal direction
|
| 98 |
+
has m_i ~ log(1+eps) ~ 0. So m_i is a *penalty scale*: higher (closer to
|
| 99 |
+
0) means "this sample carries a direction nothing else covers".
|
| 100 |
+
"""
|
| 101 |
+
E = np.asarray(E, dtype=np.float64)
|
| 102 |
+
G = E.shape[0]
|
| 103 |
+
if G < 2:
|
| 104 |
+
return np.zeros(G, dtype=np.float64)
|
| 105 |
+
L = cosine_kernel(E, eps)
|
| 106 |
+
full = _logdet_psd(L)
|
| 107 |
+
out = np.empty(G, dtype=np.float64)
|
| 108 |
+
idx = np.arange(G)
|
| 109 |
+
for i in range(G):
|
| 110 |
+
keep = idx[idx != i]
|
| 111 |
+
out[i] = full - _logdet_psd(L[np.ix_(keep, keep)])
|
| 112 |
+
return out
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def effective_rank(E: np.ndarray, eps: float = 1e-12) -> float:
|
| 116 |
+
"""exp(Shannon entropy of the normalized Gram spectrum). Roy & Vetterli.
|
| 117 |
+
|
| 118 |
+
Reads as "how many distinct directions does this set effectively span":
|
| 119 |
+
1.0 for identical stories, G for mutually orthogonal ones, ~2 for two tight
|
| 120 |
+
clusters. Reported as a METRIC, never used as a reward (it is set-level, so
|
| 121 |
+
it is constant within a group and would produce zero advantage).
|
| 122 |
+
|
| 123 |
+
This exists because silhouette-selected k-means turned out to be unusable as
|
| 124 |
+
a mode counter on this data: a fully collapsed set and a fully spread set
|
| 125 |
+
both peak near silhouette 0.20 (at k=4 and k=7 respectively), because
|
| 126 |
+
k-means partitions isotropic data happily regardless of spread. Effective
|
| 127 |
+
rank has no k to select and degrades gracefully.
|
| 128 |
+
"""
|
| 129 |
+
E = np.asarray(E, dtype=np.float64)
|
| 130 |
+
if E.shape[0] == 0:
|
| 131 |
+
return 0.0
|
| 132 |
+
if E.shape[0] == 1:
|
| 133 |
+
return 1.0
|
| 134 |
+
L = E @ E.T
|
| 135 |
+
w = np.linalg.eigvalsh(0.5 * (L + L.T))
|
| 136 |
+
w = np.clip(w, 0.0, None)
|
| 137 |
+
s = w.sum()
|
| 138 |
+
if s <= 0:
|
| 139 |
+
return 1.0
|
| 140 |
+
p = w / s
|
| 141 |
+
p = p[p > eps]
|
| 142 |
+
return float(np.exp(-(p * np.log(p)).sum()))
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def zscore(x: np.ndarray, ddof: int = 0) -> np.ndarray:
|
| 146 |
+
"""Per-group z-score. Constant input -> all zeros (not NaN).
|
| 147 |
+
|
| 148 |
+
Used on m_i before it enters the reward: raw m_i lives on a log scale
|
| 149 |
+
with a long negative tail (a duplicate pair can hit log(1e-3) ~ -6.9),
|
| 150 |
+
which would otherwise dominate the quality term.
|
| 151 |
+
"""
|
| 152 |
+
x = np.asarray(x, dtype=np.float64)
|
| 153 |
+
if x.size == 0:
|
| 154 |
+
return x
|
| 155 |
+
s = x.std(ddof=ddof)
|
| 156 |
+
if not np.isfinite(s) or s < 1e-12:
|
| 157 |
+
return np.zeros_like(x)
|
| 158 |
+
return (x - x.mean()) / s
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def scale_to_reference(x: np.ndarray, ref_scale: float = 1.0) -> np.ndarray:
|
| 162 |
+
"""Rescale x to have unit-ish spread times ref_scale, preserving mean 0.
|
| 163 |
+
|
| 164 |
+
Deviation d_i lives in [0, 2] but in practice clusters in [0.1, 0.5] for
|
| 165 |
+
same-prompt stories -- an order of magnitude below judge quality (0-10).
|
| 166 |
+
Feeding raw d_i with alpha=0.5 would make the diversity term invisible.
|
| 167 |
+
"""
|
| 168 |
+
return zscore(x) * float(ref_scale)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def greedy_diverse_subset(
|
| 172 |
+
quality: np.ndarray,
|
| 173 |
+
E: np.ndarray,
|
| 174 |
+
k: int,
|
| 175 |
+
lam: float = 1.0,
|
| 176 |
+
eps: float = EPS_JITTER,
|
| 177 |
+
) -> list[int]:
|
| 178 |
+
"""Greedily pick k indices maximizing sum(quality) + lam * logdet(L_S).
|
| 179 |
+
|
| 180 |
+
Standard submodular greedy: the objective is monotone-ish and DPP logdet
|
| 181 |
+
is submodular, so greedy carries the usual (1 - 1/e) flavor of guarantee.
|
| 182 |
+
Used to build E3's multi-positive chosen sets.
|
| 183 |
+
|
| 184 |
+
Returns indices into the rows of E, in selection order.
|
| 185 |
+
"""
|
| 186 |
+
quality = np.asarray(quality, dtype=np.float64)
|
| 187 |
+
E = np.asarray(E, dtype=np.float64)
|
| 188 |
+
G = E.shape[0]
|
| 189 |
+
k = int(min(k, G))
|
| 190 |
+
if k <= 0:
|
| 191 |
+
return []
|
| 192 |
+
L = cosine_kernel(E, eps)
|
| 193 |
+
|
| 194 |
+
chosen: list[int] = []
|
| 195 |
+
for _ in range(k):
|
| 196 |
+
best_gain, best_i = -np.inf, -1
|
| 197 |
+
for i in range(G):
|
| 198 |
+
if i in chosen:
|
| 199 |
+
continue
|
| 200 |
+
cand = chosen + [i]
|
| 201 |
+
vol = _logdet_psd(L[np.ix_(cand, cand)])
|
| 202 |
+
gain = quality[cand].sum() + lam * vol
|
| 203 |
+
if gain > best_gain:
|
| 204 |
+
best_gain, best_i = gain, i
|
| 205 |
+
if best_i < 0:
|
| 206 |
+
break
|
| 207 |
+
chosen.append(best_i)
|
| 208 |
+
return chosen
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def group_metrics(E: np.ndarray, eps: float = EPS_JITTER) -> dict:
|
| 212 |
+
"""Bundle of reporting metrics for one group. Metrics only, not rewards."""
|
| 213 |
+
E = np.asarray(E, dtype=np.float64)
|
| 214 |
+
G = E.shape[0]
|
| 215 |
+
if G < 2:
|
| 216 |
+
return {"n": G, "mean_pairwise_dist": 0.0, "logdet": 0.0,
|
| 217 |
+
"mean_marginal": 0.0, "min_marginal": 0.0}
|
| 218 |
+
d = pairwise_deviation(E)
|
| 219 |
+
m = marginal_contributions(E, eps)
|
| 220 |
+
return {
|
| 221 |
+
"n": G,
|
| 222 |
+
"mean_pairwise_dist": float(d.mean()),
|
| 223 |
+
"logdet": float(logdet_volume(E, eps)),
|
| 224 |
+
"mean_marginal": float(m.mean()),
|
| 225 |
+
"min_marginal": float(m.min()),
|
| 226 |
+
}
|
src/evaluate.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluation harness. Runs the IDENTICAL protocol on every model (base, E0, E1,
|
| 3 |
+
E2, E3, E4-emb, E4-prob, and any 8B arms): 50 held-out prompts x 16 samples at
|
| 4 |
+
fixed T=0.9, top_p=0.95, fixed seed.
|
| 5 |
+
|
| 6 |
+
Metrics
|
| 7 |
+
-------
|
| 8 |
+
quality mean judge quality over gate-passing stories
|
| 9 |
+
gate_pass fraction passing all programmatic gates
|
| 10 |
+
ends_cleanly fraction ending on terminal punctuation, not at the token cap
|
| 11 |
+
pairwise mean pairwise embedding distance within a prompt's 16 samples
|
| 12 |
+
logdet mean log-det volume of the 16-sample embedding set per prompt
|
| 13 |
+
distinct4 unique 4-grams / total 4-grams, pooled across the 16 samples
|
| 14 |
+
self_bleu mean BLEU-4 of each sample against the other 15 (LOWER = diverse)
|
| 15 |
+
n_clusters conservative count of WELL-SEPARATED modes (silhouette > 0.50)
|
| 16 |
+
eff_rank effective rank of the 16-sample Gram spectrum; the primary,
|
| 17 |
+
continuous mode measure. 1 = all identical, 16 = all orthogonal
|
| 18 |
+
tok_entropy mean per-token predictive entropy, top-20 truncated. MONITOR ONLY.
|
| 19 |
+
|
| 20 |
+
On tok_entropy: token entropy is not story diversity. A policy can raise token
|
| 21 |
+
entropy by getting noisier inside a single narrative mode, and can lower it
|
| 22 |
+
while telling structurally different stories. It is reported to detect
|
| 23 |
+
degenerate sampling, and is never optimized.
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import csv
|
| 29 |
+
import json
|
| 30 |
+
import math
|
| 31 |
+
import sys
|
| 32 |
+
from collections import Counter
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
import numpy as np
|
| 36 |
+
|
| 37 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 38 |
+
EVAL_TEMP, EVAL_TOP_P, EVAL_SEED, EVAL_N = 0.9, 0.95, 20260816, 16
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ------------------------------------------------------------------ n-grams
|
| 42 |
+
def ngrams(toks: list[str], n: int) -> list[tuple]:
|
| 43 |
+
return [tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def norm_tokens(text: str) -> list[str]:
|
| 47 |
+
import re
|
| 48 |
+
return re.sub(r"[^\w\s]", " ", text.lower()).split()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def distinct_n(texts: list[str], n: int = 4) -> float:
|
| 52 |
+
all_g = [g for t in texts for g in ngrams(norm_tokens(t), n)]
|
| 53 |
+
return len(set(all_g)) / len(all_g) if all_g else 0.0
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _bleu4(cand: list[str], refs: list[list[str]]) -> float:
|
| 57 |
+
"""BLEU-4 with brevity penalty, clipped counts against multiple refs."""
|
| 58 |
+
if len(cand) < 4:
|
| 59 |
+
return 0.0
|
| 60 |
+
logs = []
|
| 61 |
+
for n in range(1, 5):
|
| 62 |
+
cg = Counter(ngrams(cand, n))
|
| 63 |
+
if not cg:
|
| 64 |
+
return 0.0
|
| 65 |
+
maxref = Counter()
|
| 66 |
+
for r in refs:
|
| 67 |
+
for g, c in Counter(ngrams(r, n)).items():
|
| 68 |
+
if c > maxref[g]:
|
| 69 |
+
maxref[g] = c
|
| 70 |
+
clipped = sum(min(c, maxref[g]) for g, c in cg.items())
|
| 71 |
+
total = sum(cg.values())
|
| 72 |
+
# smoothing: avoid log(0) collapsing the whole score
|
| 73 |
+
logs.append(math.log((clipped + 1e-9) / total) if clipped else math.log(1e-9))
|
| 74 |
+
r_len = min((len(r) for r in refs), key=lambda L: (abs(L - len(cand)), L))
|
| 75 |
+
bp = 1.0 if len(cand) > r_len else math.exp(1 - r_len / max(1, len(cand)))
|
| 76 |
+
# clamp: the 1e-9 smoothing can push an exact-match score a hair over 1.0
|
| 77 |
+
return min(1.0, max(0.0, bp * math.exp(sum(logs) / 4)))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def self_bleu(texts: list[str]) -> float:
|
| 81 |
+
toks = [norm_tokens(t) for t in texts]
|
| 82 |
+
if len(toks) < 2:
|
| 83 |
+
return 0.0
|
| 84 |
+
return float(np.mean([
|
| 85 |
+
_bleu4(toks[i], [toks[j] for j in range(len(toks)) if j != i])
|
| 86 |
+
for i in range(len(toks))
|
| 87 |
+
]))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# Silhouette threshold for declaring that real cluster structure exists.
|
| 91 |
+
# Calibrated, not guessed: on 16 synthetic embeddings, a fully COLLAPSED set
|
| 92 |
+
# peaks at silhouette 0.202 (k=4) and a fully SPREAD set at 0.195 (k=7) -- i.e.
|
| 93 |
+
# silhouette cannot tell those apart at all, because k-means partitions
|
| 94 |
+
# isotropic data regardless of spread. Only genuinely separated clusters score
|
| 95 |
+
# high (two clear modes -> 0.976). So the threshold is set well above the
|
| 96 |
+
# isotropic band, making n_clusters a CONSERVATIVE count of well-separated
|
| 97 |
+
# modes: it returns 1 unless the structure is unmistakable. `eff_rank` is the
|
| 98 |
+
# primary, continuous mode measure.
|
| 99 |
+
SILHOUETTE_MIN = 0.50
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def cluster_count(E: np.ndarray, kmax: int = 8) -> int:
|
| 103 |
+
"""k-means with silhouette-selected k. Conservative count of *well-separated*
|
| 104 |
+
modes; returns 1 when there is no unmistakable cluster structure."""
|
| 105 |
+
from sklearn.cluster import KMeans
|
| 106 |
+
from sklearn.metrics import silhouette_score
|
| 107 |
+
n = E.shape[0]
|
| 108 |
+
if n < 4:
|
| 109 |
+
return 1
|
| 110 |
+
best_k, best_s = 1, -1.0
|
| 111 |
+
for k in range(2, min(kmax, n - 1) + 1):
|
| 112 |
+
try:
|
| 113 |
+
lab = KMeans(n_clusters=k, n_init=10, random_state=0).fit_predict(E)
|
| 114 |
+
if len(set(lab)) < 2:
|
| 115 |
+
continue
|
| 116 |
+
s = silhouette_score(E, lab, metric="cosine")
|
| 117 |
+
except Exception:
|
| 118 |
+
continue
|
| 119 |
+
if s > best_s:
|
| 120 |
+
best_k, best_s = k, s
|
| 121 |
+
return best_k if best_s > SILHOUETTE_MIN else 1
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def topk_entropy(logprob_rows: list[dict]) -> float:
|
| 125 |
+
"""Mean per-token entropy from vLLM's top-k logprob table.
|
| 126 |
+
|
| 127 |
+
Truncated at k, so it UNDERSTATES true entropy; comparable across models
|
| 128 |
+
only because every model is measured with the same k. Monitor only.
|
| 129 |
+
"""
|
| 130 |
+
ents = []
|
| 131 |
+
for row in logprob_rows:
|
| 132 |
+
lps = np.array([v for v in row.values()], dtype=np.float64)
|
| 133 |
+
if lps.size == 0:
|
| 134 |
+
continue
|
| 135 |
+
p = np.exp(lps)
|
| 136 |
+
s = p.sum()
|
| 137 |
+
if s <= 0:
|
| 138 |
+
continue
|
| 139 |
+
p = p / s
|
| 140 |
+
ents.append(float(-(p * np.log(p + 1e-12)).sum()))
|
| 141 |
+
return float(np.mean(ents)) if ents else 0.0
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# --------------------------------------------------------------------- main
|
| 145 |
+
def eval_one(model_id: str, lora_path: str | None, label: str, prompts: list[dict],
|
| 146 |
+
judge, enc, gpu_mem: float, dump_dir: Path, n_dump: int = 3) -> dict:
|
| 147 |
+
import gates
|
| 148 |
+
from diversity import (effective_rank, l2_normalize, logdet_volume,
|
| 149 |
+
pairwise_deviation)
|
| 150 |
+
from generate import build_llm, render_chat
|
| 151 |
+
from vllm import SamplingParams
|
| 152 |
+
|
| 153 |
+
from transformers import AutoTokenizer
|
| 154 |
+
tok = AutoTokenizer.from_pretrained(model_id)
|
| 155 |
+
llm = build_llm(model_id, gpu_mem_util=gpu_mem, seed=EVAL_SEED,
|
| 156 |
+
enable_lora=bool(lora_path))
|
| 157 |
+
|
| 158 |
+
kw = {}
|
| 159 |
+
if lora_path:
|
| 160 |
+
from vllm.lora.request import LoRARequest
|
| 161 |
+
kw["lora_request"] = LoRARequest(label, 1, lora_path)
|
| 162 |
+
|
| 163 |
+
# logprobs=10, not 20: this table is only used for tok_entropy, which is
|
| 164 |
+
# monitor-only and never optimized. 20 would materialize ~8M Python Logprob
|
| 165 |
+
# objects per model (800 seqs x ~500 tok x 20). The truncation understates
|
| 166 |
+
# entropy identically for every model, so cross-model comparison holds.
|
| 167 |
+
# max_tokens matches training (1024) so eval and train share a length regime.
|
| 168 |
+
sp = SamplingParams(n=EVAL_N, temperature=EVAL_TEMP, top_p=EVAL_TOP_P,
|
| 169 |
+
max_tokens=1024, seed=EVAL_SEED, logprobs=10,
|
| 170 |
+
skip_special_tokens=True)
|
| 171 |
+
outs = llm.generate([render_chat(tok, p["prompt"]) for p in prompts], sp, **kw)
|
| 172 |
+
|
| 173 |
+
records, per_prompt = [], []
|
| 174 |
+
for p, out in zip(prompts, outs):
|
| 175 |
+
texts, ents, frs = [], [], []
|
| 176 |
+
for o in out.outputs:
|
| 177 |
+
texts.append(o.text.strip())
|
| 178 |
+
frs.append(o.finish_reason or "")
|
| 179 |
+
ents.append(topk_entropy(
|
| 180 |
+
[{k: v.logprob for k, v in d.items()} for d in (o.logprobs or [])]))
|
| 181 |
+
grs = [gates.check(t, finish_reason=f) for t, f in zip(texts, frs)]
|
| 182 |
+
E = l2_normalize(np.asarray(enc.encode(
|
| 183 |
+
texts, normalize_embeddings=True, batch_size=32,
|
| 184 |
+
show_progress_bar=False, convert_to_numpy=True), dtype=np.float64))
|
| 185 |
+
per_prompt.append({
|
| 186 |
+
"prompt_id": p["id"], "prompt": p["prompt"], "texts": texts,
|
| 187 |
+
"gates": [g.as_dict() for g in grs],
|
| 188 |
+
"pairwise": float(pairwise_deviation(E).mean()),
|
| 189 |
+
"logdet": float(logdet_volume(E)),
|
| 190 |
+
"distinct4": distinct_n(texts, 4),
|
| 191 |
+
"self_bleu": self_bleu(texts),
|
| 192 |
+
"n_clusters": cluster_count(E),
|
| 193 |
+
"eff_rank": effective_rank(E),
|
| 194 |
+
"tok_entropy": float(np.mean(ents)) if ents else 0.0,
|
| 195 |
+
"gate_pass": float(np.mean([g.passed for g in grs])),
|
| 196 |
+
"ends_cleanly": float(np.mean([g.completeness for g in grs])),
|
| 197 |
+
"words": float(np.mean([g.n_words for g in grs])),
|
| 198 |
+
})
|
| 199 |
+
for t, g in zip(texts, grs):
|
| 200 |
+
records.append((p["prompt"], t, g.passed))
|
| 201 |
+
|
| 202 |
+
del llm
|
| 203 |
+
import gc, torch
|
| 204 |
+
gc.collect(); torch.cuda.empty_cache()
|
| 205 |
+
|
| 206 |
+
# judge only gate-passers, same rule as training
|
| 207 |
+
idx = [i for i, (_, _, ok) in enumerate(records) if ok]
|
| 208 |
+
scores = judge.score_many_sync([(records[i][0], records[i][1]) for i in idx]) if idx else []
|
| 209 |
+
q = [s.quality for s in scores]
|
| 210 |
+
nov = [s.novelty for s in scores]
|
| 211 |
+
|
| 212 |
+
row = {
|
| 213 |
+
"model": label,
|
| 214 |
+
"quality": float(np.mean(q)) if q else 0.0,
|
| 215 |
+
"quality_sd": float(np.std(q)) if q else 0.0,
|
| 216 |
+
"novelty": float(np.mean(nov)) if nov else 0.0,
|
| 217 |
+
"gate_pass": float(np.mean([r["gate_pass"] for r in per_prompt])),
|
| 218 |
+
"ends_cleanly": float(np.mean([r["ends_cleanly"] for r in per_prompt])),
|
| 219 |
+
"pairwise": float(np.mean([r["pairwise"] for r in per_prompt])),
|
| 220 |
+
"logdet": float(np.mean([r["logdet"] for r in per_prompt])),
|
| 221 |
+
"distinct4": float(np.mean([r["distinct4"] for r in per_prompt])),
|
| 222 |
+
"self_bleu": float(np.mean([r["self_bleu"] for r in per_prompt])),
|
| 223 |
+
"n_clusters": float(np.mean([r["n_clusters"] for r in per_prompt])),
|
| 224 |
+
"eff_rank": float(np.mean([r["eff_rank"] for r in per_prompt])),
|
| 225 |
+
"tok_entropy": float(np.mean([r["tok_entropy"] for r in per_prompt])),
|
| 226 |
+
"words": float(np.mean([r["words"] for r in per_prompt])),
|
| 227 |
+
"n_stories": len(records),
|
| 228 |
+
"n_judged": len(idx),
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
dump_dir.mkdir(parents=True, exist_ok=True)
|
| 232 |
+
json.dump(per_prompt, open(dump_dir / f"{label}_full.json", "w"), indent=1)
|
| 233 |
+
json.dump(per_prompt[:n_dump], open(dump_dir / f"{label}_examples.json", "w"), indent=1)
|
| 234 |
+
return row
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def main():
|
| 238 |
+
ap = argparse.ArgumentParser()
|
| 239 |
+
ap.add_argument("--models", required=True,
|
| 240 |
+
help="JSON list of {label, model, lora} or path to such a file")
|
| 241 |
+
ap.add_argument("--limit", type=int, default=None)
|
| 242 |
+
ap.add_argument("--gpu-mem", type=float, default=0.85)
|
| 243 |
+
ap.add_argument("--out", default="outputs/eval")
|
| 244 |
+
ap.add_argument("--single", default=None,
|
| 245 |
+
help="internal: evaluate exactly this label, then exit")
|
| 246 |
+
args = ap.parse_args()
|
| 247 |
+
|
| 248 |
+
import logbook
|
| 249 |
+
from data import load_prompts
|
| 250 |
+
from judge import build_judge
|
| 251 |
+
from sentence_transformers import SentenceTransformer
|
| 252 |
+
|
| 253 |
+
spec = json.loads(Path(args.models).read_text()) if Path(args.models).exists() \
|
| 254 |
+
else json.loads(args.models)
|
| 255 |
+
prompts = load_prompts("eval", ROOT / "data")
|
| 256 |
+
if args.limit:
|
| 257 |
+
prompts = prompts[: args.limit]
|
| 258 |
+
|
| 259 |
+
judge = build_judge(cache_path=str(ROOT / "cache" / "judge.sqlite"), concurrency=24)
|
| 260 |
+
enc = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cuda")
|
| 261 |
+
out_dir = ROOT / args.out
|
| 262 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 263 |
+
|
| 264 |
+
rowdir = out_dir / "rows"
|
| 265 |
+
rowdir.mkdir(parents=True, exist_ok=True)
|
| 266 |
+
|
| 267 |
+
# ---- child mode: evaluate one model, write its row, exit --------------
|
| 268 |
+
if args.single:
|
| 269 |
+
m = next(x for x in spec if x["label"] == args.single)
|
| 270 |
+
lora = m.get("lora")
|
| 271 |
+
if lora and not Path(ROOT / lora).exists() and not Path(lora).exists():
|
| 272 |
+
print(f"SKIP {m['label']}: adapter not found at {lora}")
|
| 273 |
+
return 3
|
| 274 |
+
r = eval_one(m["model"], lora, m["label"], prompts, judge, enc,
|
| 275 |
+
args.gpu_mem, out_dir / "samples")
|
| 276 |
+
json.dump(r, open(rowdir / f"{m['label']}.json", "w"), indent=1)
|
| 277 |
+
print(json.dumps(r, indent=1))
|
| 278 |
+
print("judge:", judge.health(), judge.cost_estimate(0.140, 0.280))
|
| 279 |
+
return 0
|
| 280 |
+
|
| 281 |
+
# ---- driver mode: one SUBPROCESS per model ---------------------------
|
| 282 |
+
# vLLM v1 runs EngineCore in a child process; `del llm` does not reliably
|
| 283 |
+
# reclaim its GPU memory, so a 7-model in-process loop OOMs on model 2 after
|
| 284 |
+
# model 1 succeeds. Process isolation makes teardown unconditional. It also
|
| 285 |
+
# means one model crashing cannot take the whole eval down.
|
| 286 |
+
import subprocess
|
| 287 |
+
for m in spec:
|
| 288 |
+
dest = rowdir / f"{m['label']}.json"
|
| 289 |
+
if dest.exists():
|
| 290 |
+
print(f"== SKIP {m['label']} (already evaluated)"); continue
|
| 291 |
+
print(f"\n===== eval {m['label']} (subprocess) =====", flush=True)
|
| 292 |
+
cmd = [sys.executable, "-u", str(Path(__file__).resolve()),
|
| 293 |
+
"--models", args.models, "--out", args.out,
|
| 294 |
+
"--gpu-mem", str(args.gpu_mem), "--single", m["label"]]
|
| 295 |
+
if args.limit:
|
| 296 |
+
cmd += ["--limit", str(args.limit)]
|
| 297 |
+
rc = subprocess.run(cmd).returncode
|
| 298 |
+
if rc != 0:
|
| 299 |
+
print(f"!! {m['label']} exited {rc}; continuing with the rest")
|
| 300 |
+
logbook.note(f"eval FAILED: {m['label']}", f"exit code {rc}", level="ALERT")
|
| 301 |
+
|
| 302 |
+
rows = []
|
| 303 |
+
for m in spec:
|
| 304 |
+
p = rowdir / f"{m['label']}.json"
|
| 305 |
+
if p.exists():
|
| 306 |
+
rows.append(json.load(open(p)))
|
| 307 |
+
if not rows:
|
| 308 |
+
print("no models evaluated"); return 1
|
| 309 |
+
with open(out_dir / "results.csv", "w", newline="") as f:
|
| 310 |
+
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
| 311 |
+
w.writeheader(); w.writerows(rows)
|
| 312 |
+
print(f"\nwrote {out_dir/'results.csv'} ({len(rows)}/{len(spec)} models)")
|
| 313 |
+
logbook.note("eval complete",
|
| 314 |
+
f"{len(rows)}/{len(spec)} models\n\n" +
|
| 315 |
+
logbook.table(rows, list(rows[0].keys())))
|
| 316 |
+
return 0
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
if __name__ == "__main__":
|
| 320 |
+
sys.exit(main())
|
src/gates.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Programmatic quality gates. Free, deterministic, zero judge variance.
|
| 3 |
+
|
| 4 |
+
Contract: a story that FAILS any hard gate gets total reward 0, regardless of
|
| 5 |
+
what the judge said. Gates run before the judge so we can skip paying for
|
| 6 |
+
obviously-broken generations.
|
| 7 |
+
|
| 8 |
+
This module exists because of a specific autopsy finding. The prior run's
|
| 9 |
+
completeness check was:
|
| 10 |
+
|
| 11 |
+
enders = ".!?\\"'"
|
| 12 |
+
text[-1] in enders
|
| 13 |
+
|
| 14 |
+
which uses straight quotes only. The policy emits curly U+201D constantly, so
|
| 15 |
+
properly-ended stories were scored 0.3 instead of 1.0. Worse, 100% of prior-run
|
| 16 |
+
eval samples were truncated mid-word by a max_new_tokens wall, and the judge
|
| 17 |
+
rubric capped quality at 4 for truncation -- collapsing within-group quality
|
| 18 |
+
variance to ~0, which in GRPO means zero advantage. The quality signal was
|
| 19 |
+
silently dead for the entire run. Everything here is built to make that
|
| 20 |
+
failure loud instead of silent.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import math
|
| 26 |
+
import re
|
| 27 |
+
import unicodedata
|
| 28 |
+
from dataclasses import dataclass, asdict, field
|
| 29 |
+
|
| 30 |
+
# Sentence terminators, including the curly/CJK variants the prior run missed.
|
| 31 |
+
TERMINALS = set('.!?…' + '"”’\'' + '。!?' + ')]}*_')
|
| 32 |
+
# Characters allowed to trail a real terminator (markdown emphasis, quotes).
|
| 33 |
+
_TRAILING_DECOR = set('*_`"”’\'» )]}')
|
| 34 |
+
|
| 35 |
+
MIN_WORDS = 150
|
| 36 |
+
MAX_WORDS = 600
|
| 37 |
+
MAX_NGRAM_REPEAT_FRAC = 0.18 # frac of 4-grams that are repeats
|
| 38 |
+
MIN_CHAR_ENTROPY = 3.2 # bits/char; English prose sits ~4.0-4.4
|
| 39 |
+
MAX_NONASCII_FRAC = 0.08 # em-dashes/curly quotes are fine; CJK dumps are not
|
| 40 |
+
MAX_LINE_REPEAT_FRAC = 0.25
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class GateResult:
|
| 45 |
+
passed: bool
|
| 46 |
+
completeness: float # 1.0 ended cleanly, 0.0 truncated
|
| 47 |
+
n_words: int
|
| 48 |
+
reasons: list[str] = field(default_factory=list)
|
| 49 |
+
# diagnostics, logged not rewarded
|
| 50 |
+
repeat_4gram_frac: float = 0.0
|
| 51 |
+
char_entropy: float = 0.0
|
| 52 |
+
nonascii_frac: float = 0.0
|
| 53 |
+
hit_token_cap: bool = False
|
| 54 |
+
|
| 55 |
+
def as_dict(self) -> dict:
|
| 56 |
+
return asdict(self)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _words(text: str) -> list[str]:
|
| 60 |
+
return text.split()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _norm_tokens(text: str) -> list[str]:
|
| 64 |
+
return re.sub(r"[^\w\s]", " ", text.lower()).split()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def ends_cleanly(text: str) -> bool:
|
| 68 |
+
"""True if the story ends on a sentence terminator.
|
| 69 |
+
|
| 70 |
+
Strips trailing markdown/quote decoration first, so `...over.*` and
|
| 71 |
+
`...done."` and `...gone.”` all count. Also rejects the specific
|
| 72 |
+
mid-word cutoff signature: last token is a bare word with no terminator.
|
| 73 |
+
"""
|
| 74 |
+
t = text.rstrip()
|
| 75 |
+
if not t:
|
| 76 |
+
return False
|
| 77 |
+
while t and t[-1] in _TRAILING_DECOR:
|
| 78 |
+
t = t[:-1].rstrip()
|
| 79 |
+
if t and t[-1] in '.!?…。!?':
|
| 80 |
+
return True
|
| 81 |
+
return bool(t) and t[-1] in '.!?…。!?'
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def char_entropy(text: str) -> float:
|
| 85 |
+
"""Shannon entropy over characters, bits/char."""
|
| 86 |
+
if not text:
|
| 87 |
+
return 0.0
|
| 88 |
+
counts: dict[str, int] = {}
|
| 89 |
+
for ch in text:
|
| 90 |
+
counts[ch] = counts.get(ch, 0) + 1
|
| 91 |
+
n = len(text)
|
| 92 |
+
return -sum((c / n) * math.log2(c / n) for c in counts.values())
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def repeat_ngram_frac(text: str, n: int = 4) -> float:
|
| 96 |
+
"""Fraction of n-grams that are non-first occurrences. Catches loops."""
|
| 97 |
+
toks = _norm_tokens(text)
|
| 98 |
+
if len(toks) < n + 1:
|
| 99 |
+
return 0.0
|
| 100 |
+
grams = [tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)]
|
| 101 |
+
return 1.0 - (len(set(grams)) / len(grams))
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def repeat_line_frac(text: str) -> float:
|
| 105 |
+
"""Fraction of non-trivial lines that are duplicates. Catches list loops."""
|
| 106 |
+
lines = [l.strip() for l in text.splitlines() if len(l.strip()) > 12]
|
| 107 |
+
if len(lines) < 4:
|
| 108 |
+
return 0.0
|
| 109 |
+
return 1.0 - (len(set(lines)) / len(lines))
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def nonascii_frac(text: str) -> float:
|
| 113 |
+
"""Fraction of chars outside Latin/punctuation. Typographic marks exempt."""
|
| 114 |
+
if not text:
|
| 115 |
+
return 0.0
|
| 116 |
+
bad = 0
|
| 117 |
+
for ch in text:
|
| 118 |
+
if ord(ch) < 128:
|
| 119 |
+
continue
|
| 120 |
+
if unicodedata.category(ch) in ("Pd", "Pi", "Pf", "Po", "Zs", "Sm"):
|
| 121 |
+
continue # em dash, curly quotes, ellipsis, nbsp
|
| 122 |
+
bad += 1
|
| 123 |
+
return bad / len(text)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def check(
|
| 127 |
+
text: str,
|
| 128 |
+
*,
|
| 129 |
+
finish_reason: str | None = None,
|
| 130 |
+
min_words: int = MIN_WORDS,
|
| 131 |
+
max_words: int = MAX_WORDS,
|
| 132 |
+
) -> GateResult:
|
| 133 |
+
"""Run all gates. `finish_reason` from vLLM/OpenAI ('length' == hit cap).
|
| 134 |
+
|
| 135 |
+
Passing `finish_reason` is strongly preferred: it detects the token-cap
|
| 136 |
+
truncation that destroyed the prior run directly, rather than inferring it
|
| 137 |
+
from punctuation.
|
| 138 |
+
"""
|
| 139 |
+
text = (text or "").strip()
|
| 140 |
+
reasons: list[str] = []
|
| 141 |
+
|
| 142 |
+
n_words = len(_words(text))
|
| 143 |
+
hit_cap = finish_reason == "length"
|
| 144 |
+
complete = ends_cleanly(text) and not hit_cap
|
| 145 |
+
|
| 146 |
+
rep4 = repeat_ngram_frac(text, 4)
|
| 147 |
+
repline = repeat_line_frac(text)
|
| 148 |
+
ent = char_entropy(text)
|
| 149 |
+
na = nonascii_frac(text)
|
| 150 |
+
|
| 151 |
+
if not text:
|
| 152 |
+
reasons.append("empty")
|
| 153 |
+
if hit_cap:
|
| 154 |
+
reasons.append("hit_token_cap")
|
| 155 |
+
if not ends_cleanly(text):
|
| 156 |
+
reasons.append("no_terminal_punctuation")
|
| 157 |
+
if n_words < min_words:
|
| 158 |
+
reasons.append(f"too_short({n_words}<{min_words})")
|
| 159 |
+
if n_words > max_words:
|
| 160 |
+
reasons.append(f"too_long({n_words}>{max_words})")
|
| 161 |
+
if rep4 > MAX_NGRAM_REPEAT_FRAC:
|
| 162 |
+
reasons.append(f"ngram_loop({rep4:.2f})")
|
| 163 |
+
if repline > MAX_LINE_REPEAT_FRAC:
|
| 164 |
+
reasons.append(f"line_loop({repline:.2f})")
|
| 165 |
+
if text and ent < MIN_CHAR_ENTROPY:
|
| 166 |
+
reasons.append(f"low_entropy({ent:.2f})")
|
| 167 |
+
if na > MAX_NONASCII_FRAC:
|
| 168 |
+
reasons.append(f"nonascii({na:.2f})")
|
| 169 |
+
|
| 170 |
+
return GateResult(
|
| 171 |
+
passed=not reasons,
|
| 172 |
+
completeness=1.0 if complete else 0.0,
|
| 173 |
+
n_words=n_words,
|
| 174 |
+
reasons=reasons,
|
| 175 |
+
repeat_4gram_frac=rep4,
|
| 176 |
+
char_entropy=ent,
|
| 177 |
+
nonascii_frac=na,
|
| 178 |
+
hit_token_cap=hit_cap,
|
| 179 |
+
)
|
src/generate.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
vLLM generation wrapper shared by pool building, eval, and smoke tests.
|
| 3 |
+
|
| 4 |
+
Captures per-story cumulative logprob, which E4's `divpo-prob` variant needs:
|
| 5 |
+
DivPO's probability criterion picks the LOWEST-logprob passing story as the
|
| 6 |
+
diverse chosen and the HIGHEST-logprob failing story as the common rejected.
|
| 7 |
+
The highest-probability sample in a temperature pool is, by construction, the
|
| 8 |
+
near-greedy one -- which is why this is the principled version of "reject the
|
| 9 |
+
greedy decode".
|
| 10 |
+
|
| 11 |
+
Length normalization matters here. Raw cumulative logprob scales with token
|
| 12 |
+
count, so ranking by it would just rank by length (short stories win). We keep
|
| 13 |
+
both `cumlogprob` and `mean_logprob = cumlogprob / n_tokens` and use the mean
|
| 14 |
+
for DivPO ranking.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
from dataclasses import dataclass, asdict
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
# NEVER truncate a story. The prior run died of a max_new_tokens wall, so this
|
| 24 |
+
# is sized for real headroom, not for the target length: a 500-word story is
|
| 25 |
+
# ~650-700 Qwen tokens, so 768 would clip anything that runs long and would
|
| 26 |
+
# reproduce the exact bug we are fixing. 1280 lets the model finish and overrun
|
| 27 |
+
# a little; overrun is then DETECTED by the >600-word gate rather than silently
|
| 28 |
+
# chopped. Measure the failure, don't manufacture it.
|
| 29 |
+
#
|
| 30 |
+
# Nothing downstream truncates either: the judge receives the complete story
|
| 31 |
+
# text, and embeddings are computed over the complete story text.
|
| 32 |
+
MAX_NEW_TOKENS = 1280
|
| 33 |
+
MAX_MODEL_LEN = 2048
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class Gen:
|
| 38 |
+
prompt_id: str
|
| 39 |
+
prompt: str
|
| 40 |
+
idx: int
|
| 41 |
+
text: str
|
| 42 |
+
n_tokens: int
|
| 43 |
+
cumlogprob: float
|
| 44 |
+
mean_logprob: float
|
| 45 |
+
finish_reason: str
|
| 46 |
+
|
| 47 |
+
def as_dict(self) -> dict:
|
| 48 |
+
return asdict(self)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def build_llm(
|
| 52 |
+
model: str,
|
| 53 |
+
gpu_mem_util: float = 0.85,
|
| 54 |
+
max_model_len: int = MAX_MODEL_LEN,
|
| 55 |
+
seed: int = 0,
|
| 56 |
+
enable_lora: bool = False,
|
| 57 |
+
):
|
| 58 |
+
from vllm import LLM
|
| 59 |
+
kw = dict(
|
| 60 |
+
model=model,
|
| 61 |
+
dtype="bfloat16",
|
| 62 |
+
gpu_memory_utilization=gpu_mem_util,
|
| 63 |
+
max_model_len=max_model_len,
|
| 64 |
+
seed=seed,
|
| 65 |
+
enforce_eager=False,
|
| 66 |
+
disable_log_stats=True,
|
| 67 |
+
)
|
| 68 |
+
if enable_lora:
|
| 69 |
+
kw.update(enable_lora=True, max_lora_rank=32)
|
| 70 |
+
return LLM(**kw)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def sampling_params(
|
| 74 |
+
n: int, temperature: float, top_p: float, seed: int | None,
|
| 75 |
+
max_tokens: int = MAX_NEW_TOKENS,
|
| 76 |
+
):
|
| 77 |
+
from vllm import SamplingParams
|
| 78 |
+
return SamplingParams(
|
| 79 |
+
n=n,
|
| 80 |
+
temperature=temperature,
|
| 81 |
+
top_p=top_p,
|
| 82 |
+
max_tokens=max_tokens,
|
| 83 |
+
seed=seed,
|
| 84 |
+
logprobs=0, # cumulative_logprob only; per-token table not needed
|
| 85 |
+
skip_special_tokens=True,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def render_chat(tokenizer, prompt: str) -> str:
|
| 90 |
+
"""Render the chat template, with thinking OFF where the model has it.
|
| 91 |
+
|
| 92 |
+
Qwen3-8B is a hybrid-thinking model: left at its default it emits a <think>
|
| 93 |
+
block before the story, which would (a) eat the token budget, (b) pollute
|
| 94 |
+
the embedding with reasoning text, and (c) make the 8B arm incomparable to
|
| 95 |
+
the 4B-Instruct arms, which have no thinking mode at all. Qwen3-4B-Instruct
|
| 96 |
+
ignores the kwarg, so the same call is correct for both.
|
| 97 |
+
"""
|
| 98 |
+
from data import chat_messages
|
| 99 |
+
msgs = chat_messages(prompt)
|
| 100 |
+
try:
|
| 101 |
+
return tokenizer.apply_chat_template(
|
| 102 |
+
msgs, tokenize=False, add_generation_prompt=True, enable_thinking=False,
|
| 103 |
+
)
|
| 104 |
+
except TypeError:
|
| 105 |
+
return tokenizer.apply_chat_template(
|
| 106 |
+
msgs, tokenize=False, add_generation_prompt=True,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def generate(
|
| 111 |
+
llm,
|
| 112 |
+
tokenizer,
|
| 113 |
+
prompts: list[dict],
|
| 114 |
+
n: int,
|
| 115 |
+
temperature: float,
|
| 116 |
+
top_p: float,
|
| 117 |
+
seed: int | None = None,
|
| 118 |
+
max_tokens: int = MAX_NEW_TOKENS,
|
| 119 |
+
lora_path: str | None = None,
|
| 120 |
+
) -> list[Gen]:
|
| 121 |
+
"""prompts = [{'id':..., 'prompt':...}] -> flat list of n*len(prompts) Gens."""
|
| 122 |
+
texts = [render_chat(tokenizer, p["prompt"]) for p in prompts]
|
| 123 |
+
sp = sampling_params(n, temperature, top_p, seed, max_tokens)
|
| 124 |
+
|
| 125 |
+
kw = {}
|
| 126 |
+
if lora_path:
|
| 127 |
+
from vllm.lora.request import LoRARequest
|
| 128 |
+
kw["lora_request"] = LoRARequest("adapter", 1, lora_path)
|
| 129 |
+
|
| 130 |
+
outs = llm.generate(texts, sp, **kw)
|
| 131 |
+
|
| 132 |
+
gens: list[Gen] = []
|
| 133 |
+
for p, out in zip(prompts, outs):
|
| 134 |
+
for j, o in enumerate(out.outputs):
|
| 135 |
+
ntok = len(o.token_ids)
|
| 136 |
+
cum = float(o.cumulative_logprob) if o.cumulative_logprob is not None else 0.0
|
| 137 |
+
gens.append(Gen(
|
| 138 |
+
prompt_id=p["id"], prompt=p["prompt"], idx=j,
|
| 139 |
+
text=o.text.strip(), n_tokens=ntok,
|
| 140 |
+
cumlogprob=cum,
|
| 141 |
+
mean_logprob=(cum / ntok) if ntok else 0.0,
|
| 142 |
+
finish_reason=o.finish_reason or "",
|
| 143 |
+
))
|
| 144 |
+
return gens
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def save_gens(gens: list[Gen], path: str | Path) -> None:
|
| 148 |
+
path = Path(path)
|
| 149 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 150 |
+
with open(path, "w") as f:
|
| 151 |
+
for g in gens:
|
| 152 |
+
f.write(json.dumps(g.as_dict()) + "\n")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def load_gens(path: str | Path) -> list[Gen]:
|
| 156 |
+
return [Gen(**json.loads(l)) for l in open(path) if l.strip()]
|
src/judge.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pluggable LLM judge with aggressive on-disk caching.
|
| 3 |
+
|
| 4 |
+
Design decisions worth stating, because they differ from the prior run:
|
| 5 |
+
|
| 6 |
+
1. ONE STORY PER CALL, absolute rubric. The prior run sent all 16 samples in a
|
| 7 |
+
single call and asked for relative scores. That is cheaper but wrong here:
|
| 8 |
+
- quality is compared against a fixed gate tau (5.0), so it must be
|
| 9 |
+
absolutely calibrated, not calibrated relative to whatever else happened
|
| 10 |
+
to be in the group;
|
| 11 |
+
- eval compares quality ACROSS models, which relative scoring makes
|
| 12 |
+
meaningless;
|
| 13 |
+
- batched scoring has position bias.
|
| 14 |
+
Per-story also makes the cache actually work: identical stories recur across
|
| 15 |
+
arms and eval passes, and each is paid for once, ever.
|
| 16 |
+
|
| 17 |
+
2. NO group_diversity FIELD. A set-level scalar is constant within a GRPO group
|
| 18 |
+
-> zero within-group std -> zero advantage. It cannot train anything. All
|
| 19 |
+
diversity credit comes from embeddings (src/diversity.py), where it is
|
| 20 |
+
per-sample by construction.
|
| 21 |
+
|
| 22 |
+
3. Judge novelty is an ABSOLUTE 'freshness vs. generic AI slop' axis, not
|
| 23 |
+
'different from the others'. The latter is the embeddings' job. This keeps
|
| 24 |
+
the two signals from being redundant-but-inconsistent.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import asyncio
|
| 30 |
+
import hashlib
|
| 31 |
+
import json
|
| 32 |
+
import os
|
| 33 |
+
import sqlite3
|
| 34 |
+
import time
|
| 35 |
+
from dataclasses import dataclass, asdict
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
|
| 38 |
+
RUBRIC_VERSION = "v4"
|
| 39 |
+
|
| 40 |
+
SYSTEM_RUBRIC = """You are a demanding fiction editor scoring ONE short story written to a prompt.
|
| 41 |
+
Score on absolute standards, not relative to anything you have seen before.
|
| 42 |
+
|
| 43 |
+
STEP 1 -- FINISHEDNESS. Read the LAST sentence first, before anything else.
|
| 44 |
+
A story is UNFINISHED if the final sentence stops mid-clause or mid-word, or if
|
| 45 |
+
the piece simply halts with the situation unresolved and no closing beat.
|
| 46 |
+
Beautiful prose does not make an unfinished story finished. If UNFINISHED:
|
| 47 |
+
set "ended":false, quality 1-3, novelty 0-3, and stop deliberating -- an
|
| 48 |
+
unfinished fragment is never novel, because you cannot know what it became.
|
| 49 |
+
Only if it is FINISHED, continue to step 2.
|
| 50 |
+
|
| 51 |
+
STEP 2 -- quality (0-10): coherence + prompt fulfilment + craft
|
| 52 |
+
0-2 Incoherent, or ignores the prompt entirely.
|
| 53 |
+
3-4 Followable but generic: stock premise, flat prose, perfunctory ending.
|
| 54 |
+
5-6 Competent. Does what the prompt asks, reads cleanly, resolves. Forgettable.
|
| 55 |
+
7-8 Strong. Controlled voice, purposeful structure, an ending that lands.
|
| 56 |
+
9-10 Excellent. Precise images, earned emotional turn, nothing wasted.
|
| 57 |
+
If it never engages the prompt's actual premise, quality <= 4.
|
| 58 |
+
|
| 59 |
+
STEP 3 -- novelty (0-10): freshness of premise, voice and construction vs. generic AI fiction
|
| 60 |
+
0-2 Pure slop signature: portentous one-line paragraphs, "It wasn't X, it was Y",
|
| 61 |
+
italicised abstract nouns doing the emotional work, cosmic-melancholy fog.
|
| 62 |
+
3-4 Familiar premise handled in the familiar way.
|
| 63 |
+
5-6 One genuine choice (an unusual angle OR a distinct voice OR an odd structure).
|
| 64 |
+
7-8 Several: takes a real swing on premise, form or register and controls it.
|
| 65 |
+
9-10 Startling and still coherent. You could not have predicted this.
|
| 66 |
+
Novelty is NOT weirdness: incoherence, non-sequiturs and word salad score 0-2.
|
| 67 |
+
Judge freshness against fiction in general, NOT against other samples.
|
| 68 |
+
|
| 69 |
+
Return ONLY minified JSON, no markdown fence:
|
| 70 |
+
{"quality":<0-10>,"novelty":<0-10>,"ended":<true|false>,"note":"<=10 words"}"""
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@dataclass
|
| 74 |
+
class JudgeScore:
|
| 75 |
+
quality: float
|
| 76 |
+
novelty: float
|
| 77 |
+
ended: bool = True
|
| 78 |
+
note: str = ""
|
| 79 |
+
ok: bool = True # False => call failed, scores are neutral fallbacks
|
| 80 |
+
cached: bool = False
|
| 81 |
+
|
| 82 |
+
def as_dict(self) -> dict:
|
| 83 |
+
return asdict(self)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
NEUTRAL = JudgeScore(quality=5.0, novelty=5.0, ended=True, note="judge_failed", ok=False)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# --------------------------------------------------------------- disk cache
|
| 90 |
+
class ScoreCache:
|
| 91 |
+
"""sqlite keyed by sha256(model|rubric|prompt|story). Survives restarts."""
|
| 92 |
+
|
| 93 |
+
def __init__(self, path: str | Path):
|
| 94 |
+
self.path = str(path)
|
| 95 |
+
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
| 96 |
+
self._db = sqlite3.connect(self.path, check_same_thread=False)
|
| 97 |
+
self._db.execute("PRAGMA journal_mode=WAL")
|
| 98 |
+
self._db.execute(
|
| 99 |
+
"CREATE TABLE IF NOT EXISTS scores ("
|
| 100 |
+
" k TEXT PRIMARY KEY, quality REAL, novelty REAL,"
|
| 101 |
+
" ended INT, note TEXT, ts REAL)"
|
| 102 |
+
)
|
| 103 |
+
self._db.commit()
|
| 104 |
+
self.hits = 0
|
| 105 |
+
self.misses = 0
|
| 106 |
+
|
| 107 |
+
@staticmethod
|
| 108 |
+
def key(model: str, prompt: str, story: str) -> str:
|
| 109 |
+
h = hashlib.sha256()
|
| 110 |
+
h.update(f"{model}\x00{RUBRIC_VERSION}\x00{prompt}\x00{story}".encode())
|
| 111 |
+
return h.hexdigest()
|
| 112 |
+
|
| 113 |
+
def get(self, k: str) -> JudgeScore | None:
|
| 114 |
+
r = self._db.execute(
|
| 115 |
+
"SELECT quality,novelty,ended,note FROM scores WHERE k=?", (k,)
|
| 116 |
+
).fetchone()
|
| 117 |
+
if r is None:
|
| 118 |
+
self.misses += 1
|
| 119 |
+
return None
|
| 120 |
+
self.hits += 1
|
| 121 |
+
return JudgeScore(quality=r[0], novelty=r[1], ended=bool(r[2]),
|
| 122 |
+
note=r[3] or "", ok=True, cached=True)
|
| 123 |
+
|
| 124 |
+
def put(self, k: str, s: JudgeScore) -> None:
|
| 125 |
+
if not s.ok:
|
| 126 |
+
return # never cache a failure
|
| 127 |
+
self._db.execute(
|
| 128 |
+
"INSERT OR REPLACE INTO scores VALUES (?,?,?,?,?,?)",
|
| 129 |
+
(k, s.quality, s.novelty, int(s.ended), s.note[:120], time.time()),
|
| 130 |
+
)
|
| 131 |
+
self._db.commit()
|
| 132 |
+
|
| 133 |
+
def stats(self) -> dict:
|
| 134 |
+
n = self._db.execute("SELECT COUNT(*) FROM scores").fetchone()[0]
|
| 135 |
+
tot = self.hits + self.misses
|
| 136 |
+
return {"rows": n, "hits": self.hits, "misses": self.misses,
|
| 137 |
+
"hit_rate": (self.hits / tot) if tot else 0.0}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ------------------------------------------------------------------- judges
|
| 141 |
+
def _parse(text: str) -> JudgeScore:
|
| 142 |
+
t = (text or "").strip()
|
| 143 |
+
if t.startswith("```"):
|
| 144 |
+
t = t.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
| 145 |
+
i, j = t.find("{"), t.rfind("}")
|
| 146 |
+
if i >= 0 and j > i:
|
| 147 |
+
t = t[i:j + 1]
|
| 148 |
+
d = json.loads(t)
|
| 149 |
+
|
| 150 |
+
def num(v, lo=0.0, hi=10.0):
|
| 151 |
+
return max(lo, min(hi, float(v)))
|
| 152 |
+
|
| 153 |
+
return JudgeScore(
|
| 154 |
+
quality=num(d.get("quality", 5)),
|
| 155 |
+
novelty=num(d.get("novelty", d.get("novel", 5))),
|
| 156 |
+
ended=bool(d.get("ended", True)),
|
| 157 |
+
note=str(d.get("note", ""))[:120],
|
| 158 |
+
ok=True,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class OpenRouterJudge:
|
| 163 |
+
"""Async judge over OpenRouter. Default backend."""
|
| 164 |
+
|
| 165 |
+
BASE = "https://openrouter.ai/api/v1/chat/completions"
|
| 166 |
+
|
| 167 |
+
def __init__(
|
| 168 |
+
self,
|
| 169 |
+
model: str = "deepseek/deepseek-v4-flash-0731",
|
| 170 |
+
api_key: str | None = None,
|
| 171 |
+
cache_path: str | Path = "cache/judge.sqlite",
|
| 172 |
+
concurrency: int = 12,
|
| 173 |
+
max_retries: int = 4,
|
| 174 |
+
temperature: float = 0.0,
|
| 175 |
+
timeout: float = 120.0,
|
| 176 |
+
):
|
| 177 |
+
self.model = model
|
| 178 |
+
self.api_key = api_key or os.environ.get("OPENROUTER_API_KEY", "")
|
| 179 |
+
if not self.api_key:
|
| 180 |
+
raise RuntimeError("OPENROUTER_API_KEY not set")
|
| 181 |
+
self.cache = ScoreCache(cache_path)
|
| 182 |
+
# NOT created here. asyncio.Semaphore binds to the loop that first
|
| 183 |
+
# awaits it, and score_many_sync() calls asyncio.run() -> a NEW loop
|
| 184 |
+
# every training step. A semaphore built in __init__ survives step 1 and
|
| 185 |
+
# then raises "bound to a different event loop" on step 2, which our
|
| 186 |
+
# retry wrapper would convert into neutral 5.0 scores forever. Built
|
| 187 |
+
# per-call in score_many() instead.
|
| 188 |
+
self.concurrency = concurrency
|
| 189 |
+
self.sem: asyncio.Semaphore | None = None
|
| 190 |
+
self.max_retries = max_retries
|
| 191 |
+
self.temperature = temperature
|
| 192 |
+
self.timeout = timeout
|
| 193 |
+
self.n_calls = 0
|
| 194 |
+
self.tok_in = 0
|
| 195 |
+
self.tok_out = 0
|
| 196 |
+
self.n_failed = 0
|
| 197 |
+
self.n_empty = 0
|
| 198 |
+
self.n_truncated = 0
|
| 199 |
+
self.last_error = ""
|
| 200 |
+
|
| 201 |
+
def _user(self, prompt: str, story: str) -> str:
|
| 202 |
+
return f"WRITING PROMPT:\n{prompt}\n\nSTORY:\n{story}\n\nJSON only:"
|
| 203 |
+
|
| 204 |
+
async def _one(self, client, prompt: str, story: str) -> JudgeScore:
|
| 205 |
+
payload = {
|
| 206 |
+
"model": self.model,
|
| 207 |
+
"messages": [
|
| 208 |
+
{"role": "system", "content": SYSTEM_RUBRIC},
|
| 209 |
+
{"role": "user", "content": self._user(prompt, story)},
|
| 210 |
+
],
|
| 211 |
+
"temperature": self.temperature,
|
| 212 |
+
# deepseek-v4-flash-0731 is a REASONING model. Left alone it spends
|
| 213 |
+
# its whole output budget on `reasoning` and returns content=null
|
| 214 |
+
# with finish_reason="length" -- which our fallback silently turned
|
| 215 |
+
# into a neutral 5.0, i.e. a judge that scored everything identically
|
| 216 |
+
# while looking like it worked. Calibration caught it at 4/166 calls
|
| 217 |
+
# succeeding. Disabling reasoning is also 25x cheaper and 5x faster
|
| 218 |
+
# (30 output tokens vs 764).
|
| 219 |
+
"reasoning": {"enabled": False},
|
| 220 |
+
# Generous bound on a structured output that empirically needs ~30
|
| 221 |
+
# tokens. Not a length cap on content we care about -- and if it is
|
| 222 |
+
# ever hit, `n_truncated` below makes it loud instead of silent.
|
| 223 |
+
"max_tokens": 300,
|
| 224 |
+
}
|
| 225 |
+
delay = 2.0
|
| 226 |
+
for attempt in range(self.max_retries):
|
| 227 |
+
try:
|
| 228 |
+
async with self.sem:
|
| 229 |
+
r = await client.post(
|
| 230 |
+
self.BASE, json=payload, timeout=self.timeout,
|
| 231 |
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
| 232 |
+
)
|
| 233 |
+
if r.status_code in (429, 500, 502, 503, 529):
|
| 234 |
+
await asyncio.sleep(delay); delay *= 2
|
| 235 |
+
continue
|
| 236 |
+
r.raise_for_status()
|
| 237 |
+
body = r.json()
|
| 238 |
+
self.n_calls += 1
|
| 239 |
+
u = body.get("usage") or {}
|
| 240 |
+
self.tok_in += int(u.get("prompt_tokens", 0) or 0)
|
| 241 |
+
self.tok_out += int(u.get("completion_tokens", 0) or 0)
|
| 242 |
+
ch = body["choices"][0]
|
| 243 |
+
if ch.get("finish_reason") == "length":
|
| 244 |
+
self.n_truncated += 1
|
| 245 |
+
content = ch["message"].get("content")
|
| 246 |
+
if not content:
|
| 247 |
+
self.n_empty += 1
|
| 248 |
+
raise ValueError("empty content (reasoning ate the budget?)")
|
| 249 |
+
return _parse(content)
|
| 250 |
+
except Exception as e:
|
| 251 |
+
self.last_error = f"{type(e).__name__}: {str(e)[:140]}"
|
| 252 |
+
if attempt == self.max_retries - 1:
|
| 253 |
+
self.n_failed += 1
|
| 254 |
+
return NEUTRAL
|
| 255 |
+
await asyncio.sleep(delay); delay *= 2
|
| 256 |
+
self.n_failed += 1
|
| 257 |
+
return NEUTRAL
|
| 258 |
+
|
| 259 |
+
def health(self) -> dict:
|
| 260 |
+
"""Fail-loud accounting. A judge that returns neutral 5.0 for every call
|
| 261 |
+
looks exactly like a judge that works, until you check this."""
|
| 262 |
+
att = self.n_calls + self.n_failed
|
| 263 |
+
return {"calls_ok": self.n_calls, "failed": self.n_failed,
|
| 264 |
+
"empty_content": self.n_empty, "truncated": self.n_truncated,
|
| 265 |
+
"fail_rate": (self.n_failed / att) if att else 0.0,
|
| 266 |
+
"last_error": self.last_error}
|
| 267 |
+
|
| 268 |
+
def assert_healthy(self, max_fail_rate: float = 0.05) -> None:
|
| 269 |
+
h = self.health()
|
| 270 |
+
if h["fail_rate"] > max_fail_rate:
|
| 271 |
+
raise RuntimeError(
|
| 272 |
+
f"JUDGE UNHEALTHY: {h}. Refusing to train on neutral fallbacks -- "
|
| 273 |
+
f"a constant reward column produces zero GRPO advantage."
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
async def score_many(self, pairs: list[tuple[str, str]]) -> list[JudgeScore]:
|
| 277 |
+
"""pairs = [(prompt, story), ...] -> scores in the same order."""
|
| 278 |
+
import httpx
|
| 279 |
+
|
| 280 |
+
out: list[JudgeScore | None] = [None] * len(pairs)
|
| 281 |
+
todo: list[int] = []
|
| 282 |
+
for i, (p, s) in enumerate(pairs):
|
| 283 |
+
hit = self.cache.get(self.cache.key(self.model, p, s))
|
| 284 |
+
if hit is not None:
|
| 285 |
+
out[i] = hit
|
| 286 |
+
else:
|
| 287 |
+
todo.append(i)
|
| 288 |
+
|
| 289 |
+
if todo:
|
| 290 |
+
self.sem = asyncio.Semaphore(self.concurrency) # bind to THIS loop
|
| 291 |
+
|
| 292 |
+
async def one_and_cache(i):
|
| 293 |
+
"""Persist each score the moment it lands, not after the whole
|
| 294 |
+
batch. The pool stage issues ~16k calls in a single
|
| 295 |
+
score_many(); caching only at the end meant a crash at 99%
|
| 296 |
+
threw away every call. Incremental writes make any failure
|
| 297 |
+
resumable for free -- a re-run replays as cache hits."""
|
| 298 |
+
s = await self._one(client, pairs[i][0], pairs[i][1])
|
| 299 |
+
if s.ok:
|
| 300 |
+
self.cache.put(self.cache.key(self.model, *pairs[i]), s)
|
| 301 |
+
return i, s
|
| 302 |
+
|
| 303 |
+
async with httpx.AsyncClient(http2=False) as client:
|
| 304 |
+
for fut in asyncio.as_completed([one_and_cache(i) for i in todo]):
|
| 305 |
+
i, s = await fut
|
| 306 |
+
out[i] = s
|
| 307 |
+
|
| 308 |
+
return [o if o is not None else NEUTRAL for o in out]
|
| 309 |
+
|
| 310 |
+
def score_many_sync(self, pairs: list[tuple[str, str]]) -> list[JudgeScore]:
|
| 311 |
+
"""Blocking wrapper for use inside TRL reward functions."""
|
| 312 |
+
try:
|
| 313 |
+
asyncio.get_running_loop()
|
| 314 |
+
except RuntimeError:
|
| 315 |
+
return asyncio.run(self.score_many(pairs))
|
| 316 |
+
# already inside a loop (rare in TRL): run in a private one
|
| 317 |
+
import concurrent.futures
|
| 318 |
+
with concurrent.futures.ThreadPoolExecutor(1) as ex:
|
| 319 |
+
return ex.submit(asyncio.run, self.score_many(pairs)).result()
|
| 320 |
+
|
| 321 |
+
def cost_estimate(self, price_in: float, price_out: float) -> dict:
|
| 322 |
+
"""price_* in $ per 1M tokens."""
|
| 323 |
+
c = self.tok_in / 1e6 * price_in + self.tok_out / 1e6 * price_out
|
| 324 |
+
return {"calls": self.n_calls, "tok_in": self.tok_in,
|
| 325 |
+
"tok_out": self.tok_out, "usd": round(c, 4),
|
| 326 |
+
**self.cache.stats()}
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def build_judge(**kw) -> OpenRouterJudge:
|
| 330 |
+
"""Factory. Extend here if an Anthropic/OpenAI key shows up."""
|
| 331 |
+
return OpenRouterJudge(**kw)
|
src/logbook.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Lab-notebook helper. Everything the run learns goes to logs/ as markdown +
|
| 3 |
+
figures, so the record survives the process that produced it.
|
| 4 |
+
|
| 5 |
+
Layout:
|
| 6 |
+
logs/lab_notebook.md chronological running journal (append-only)
|
| 7 |
+
logs/experiments/<name>.md one long report per experiment
|
| 8 |
+
logs/figures/<name>.png figures
|
| 9 |
+
logs/disk.md space accounting
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import subprocess
|
| 16 |
+
import time
|
| 17 |
+
from datetime import datetime, timezone
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 21 |
+
LOGS = ROOT / "logs"
|
| 22 |
+
FIGS = LOGS / "figures"
|
| 23 |
+
EXPS = LOGS / "experiments"
|
| 24 |
+
NOTEBOOK = LOGS / "lab_notebook.md"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _ts() -> str:
|
| 28 |
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _ensure() -> None:
|
| 32 |
+
for d in (LOGS, FIGS, EXPS):
|
| 33 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def note(title: str, body: str = "", level: str = "INFO") -> None:
|
| 37 |
+
"""Append a timestamped entry to the running journal."""
|
| 38 |
+
_ensure()
|
| 39 |
+
with open(NOTEBOOK, "a") as f:
|
| 40 |
+
f.write(f"\n### [{_ts()}] {level} — {title}\n\n")
|
| 41 |
+
if body:
|
| 42 |
+
f.write(body.rstrip() + "\n")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def table(rows: list[dict], cols: list[str] | None = None) -> str:
|
| 46 |
+
"""Render list-of-dicts as a markdown table."""
|
| 47 |
+
if not rows:
|
| 48 |
+
return "_(empty)_\n"
|
| 49 |
+
cols = cols or list(rows[0].keys())
|
| 50 |
+
|
| 51 |
+
def fmt(v):
|
| 52 |
+
if isinstance(v, float):
|
| 53 |
+
return f"{v:.4g}"
|
| 54 |
+
return str(v)
|
| 55 |
+
|
| 56 |
+
out = ["| " + " | ".join(cols) + " |",
|
| 57 |
+
"|" + "|".join("---" for _ in cols) + "|"]
|
| 58 |
+
for r in rows:
|
| 59 |
+
out.append("| " + " | ".join(fmt(r.get(c, "")) for c in cols) + " |")
|
| 60 |
+
return "\n".join(out) + "\n"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def write_report(name: str, content: str) -> Path:
|
| 64 |
+
"""Write/overwrite one experiment's full report."""
|
| 65 |
+
_ensure()
|
| 66 |
+
p = EXPS / f"{name}.md"
|
| 67 |
+
p.write_text(content)
|
| 68 |
+
return p
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def gpu_snapshot() -> dict:
|
| 72 |
+
try:
|
| 73 |
+
q = ("--query-gpu=memory.used,memory.total,utilization.gpu,temperature.gpu"
|
| 74 |
+
",power.draw")
|
| 75 |
+
out = subprocess.run(
|
| 76 |
+
["nvidia-smi", q, "--format=csv,noheader,nounits"],
|
| 77 |
+
capture_output=True, text=True, timeout=20).stdout.strip()
|
| 78 |
+
u, t, g, temp, pw = [x.strip() for x in out.split(",")]
|
| 79 |
+
return {"vram_used_mb": int(u), "vram_total_mb": int(t),
|
| 80 |
+
"gpu_util_pct": int(g), "temp_c": int(temp), "power_w": float(pw)}
|
| 81 |
+
except Exception as e:
|
| 82 |
+
return {"error": str(e)}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def disk_snapshot(paths: list[str] | None = None) -> dict:
|
| 86 |
+
paths = paths or [str(ROOT)]
|
| 87 |
+
out = {}
|
| 88 |
+
try:
|
| 89 |
+
df = subprocess.run(["df", "-BM", "--output=avail,used,size", str(ROOT)],
|
| 90 |
+
capture_output=True, text=True, timeout=20).stdout
|
| 91 |
+
parts = df.strip().splitlines()[-1].split()
|
| 92 |
+
out["fs_avail_mb"] = int(parts[0].rstrip("M"))
|
| 93 |
+
out["fs_used_mb"] = int(parts[1].rstrip("M"))
|
| 94 |
+
except Exception as e:
|
| 95 |
+
out["df_error"] = str(e)
|
| 96 |
+
for p in paths:
|
| 97 |
+
try:
|
| 98 |
+
du = subprocess.run(["du", "-sm", p], capture_output=True,
|
| 99 |
+
text=True, timeout=120).stdout.split()[0]
|
| 100 |
+
out[f"du_mb:{Path(p).name}"] = int(du)
|
| 101 |
+
except Exception:
|
| 102 |
+
pass
|
| 103 |
+
return out
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def checkpoint(stage: str, extra: dict | None = None) -> dict:
|
| 107 |
+
"""One-line health snapshot appended to the journal."""
|
| 108 |
+
snap = {"stage": stage, "gpu": gpu_snapshot(), "disk": disk_snapshot()}
|
| 109 |
+
if extra:
|
| 110 |
+
snap.update(extra)
|
| 111 |
+
note(f"checkpoint: {stage}", "```json\n" + json.dumps(snap, indent=1) + "\n```")
|
| 112 |
+
return snap
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class Timer:
|
| 116 |
+
def __init__(self, label: str):
|
| 117 |
+
self.label = label
|
| 118 |
+
|
| 119 |
+
def __enter__(self):
|
| 120 |
+
self.t0 = time.time()
|
| 121 |
+
return self
|
| 122 |
+
|
| 123 |
+
def __exit__(self, *a):
|
| 124 |
+
self.dt = time.time() - self.t0
|
| 125 |
+
note(f"timing: {self.label}", f"`{self.dt:.1f}s` ({self.dt/60:.1f} min)")
|
src/make_report.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Final cross-model report: results table, quality-vs-diversity frontier chart,
|
| 3 |
+
and qualitative examples. Reads outputs/eval/results.csv.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import argparse
|
| 8 |
+
import csv
|
| 9 |
+
import json
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
import logbook
|
| 16 |
+
|
| 17 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 18 |
+
|
| 19 |
+
ORDER = ["base", "E0-baseline", "E1-div-individual", "E2-div-group",
|
| 20 |
+
"E3-multipos", "E4-divpo-emb", "E4-divpo-prob"]
|
| 21 |
+
|
| 22 |
+
PRETTY = {
|
| 23 |
+
"base": "Base (Qwen3-4B-Instruct)",
|
| 24 |
+
"E0-baseline": "E0 · GRPO, quality only",
|
| 25 |
+
"E1-div-individual": "E1 · div-grpo-individual (d_i)",
|
| 26 |
+
"E2-div-group": "E2 · div-grpo-group (d_i + m_i)",
|
| 27 |
+
"E3-multipos": "E3 · multi-positive weighted DPO",
|
| 28 |
+
"E4-divpo-emb": "E4a · DivPO (embedding)",
|
| 29 |
+
"E4-divpo-prob": "E4b · DivPO (probability)",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load(path: Path) -> list[dict]:
|
| 34 |
+
rows = list(csv.DictReader(open(path)))
|
| 35 |
+
for r in rows:
|
| 36 |
+
for k, v in r.items():
|
| 37 |
+
if k != "model":
|
| 38 |
+
try:
|
| 39 |
+
r[k] = float(v)
|
| 40 |
+
except (TypeError, ValueError):
|
| 41 |
+
pass
|
| 42 |
+
key = {m: i for i, m in enumerate(ORDER)}
|
| 43 |
+
rows.sort(key=lambda r: key.get(r["model"], 99))
|
| 44 |
+
return rows
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def frontier_chart(rows, out_path):
|
| 48 |
+
import matplotlib
|
| 49 |
+
matplotlib.use("Agg")
|
| 50 |
+
import matplotlib.pyplot as plt
|
| 51 |
+
|
| 52 |
+
fig, ax = plt.subplots(1, 3, figsize=(17, 5.2))
|
| 53 |
+
cols = plt.cm.tab10(np.linspace(0, 1, 10))
|
| 54 |
+
|
| 55 |
+
panels = [
|
| 56 |
+
("pairwise", "Mean pairwise embedding distance", 0),
|
| 57 |
+
("logdet", "Log-det volume (semantic coverage)", 1),
|
| 58 |
+
("eff_rank", "Effective rank (mode count)", 2),
|
| 59 |
+
]
|
| 60 |
+
base = next((r for r in rows if r["model"] == "base"), None)
|
| 61 |
+
|
| 62 |
+
for (xk, xlabel, i) in panels:
|
| 63 |
+
a = ax[i]
|
| 64 |
+
for j, r in enumerate(rows):
|
| 65 |
+
a.scatter(r[xk], r["quality"], s=170, color=cols[j % 10],
|
| 66 |
+
edgecolor="black", zorder=3,
|
| 67 |
+
label=PRETTY.get(r["model"], r["model"]))
|
| 68 |
+
a.annotate(r["model"].replace("-div", "").replace("divpo-", ""),
|
| 69 |
+
(r[xk], r["quality"]), fontsize=7,
|
| 70 |
+
xytext=(5, 5), textcoords="offset points")
|
| 71 |
+
if base:
|
| 72 |
+
a.axhline(base["quality"], ls=":", c="gray", lw=1)
|
| 73 |
+
a.axvline(base[xk], ls=":", c="gray", lw=1)
|
| 74 |
+
a.set_xlabel(xlabel); a.set_ylabel("Judge quality (0-10)")
|
| 75 |
+
a.grid(alpha=.3)
|
| 76 |
+
a.set_title(f"Quality vs {xlabel.split('(')[0].strip()}", fontsize=10)
|
| 77 |
+
|
| 78 |
+
handles, labels = ax[0].get_legend_handles_labels()
|
| 79 |
+
fig.legend(handles, labels, loc="lower center", ncol=4, fontsize=8,
|
| 80 |
+
frameon=False, bbox_to_anchor=(0.5, -0.06))
|
| 81 |
+
fig.suptitle("Quality–diversity frontier (dotted lines = base model)", fontsize=13)
|
| 82 |
+
plt.tight_layout()
|
| 83 |
+
plt.savefig(out_path, dpi=140, bbox_inches="tight")
|
| 84 |
+
plt.close()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def delta_table(rows):
|
| 88 |
+
base = next((r for r in rows if r["model"] == "base"), None)
|
| 89 |
+
if not base:
|
| 90 |
+
return []
|
| 91 |
+
keys = ["quality", "pairwise", "logdet", "distinct4", "self_bleu",
|
| 92 |
+
"eff_rank", "n_clusters", "ends_cleanly"]
|
| 93 |
+
out = []
|
| 94 |
+
for r in rows:
|
| 95 |
+
d = {"model": PRETTY.get(r["model"], r["model"])}
|
| 96 |
+
for k in keys:
|
| 97 |
+
base_v = base.get(k, 0.0)
|
| 98 |
+
d[f"Δ{k}"] = round(r.get(k, 0.0) - base_v, 4)
|
| 99 |
+
out.append(d)
|
| 100 |
+
return out
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
QUALITY_TOLERANCE = 0.3 # judge points we are willing to lose for diversity
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def recommend(rows) -> str:
|
| 107 |
+
"""Pick the arm to scale, from the data rather than from vibes.
|
| 108 |
+
|
| 109 |
+
Rule, stated before looking at results: among arms whose quality is within
|
| 110 |
+
QUALITY_TOLERANCE of the BASE model (not of the best arm -- we care about not
|
| 111 |
+
degrading the model, not about winning on quality), take the largest gain in
|
| 112 |
+
effective rank. Effective rank is the primary diversity measure because it is
|
| 113 |
+
the one metric shown to separate collapse from spread (see 02_setup). Ties
|
| 114 |
+
and near-ties are reported rather than hidden.
|
| 115 |
+
"""
|
| 116 |
+
base = next((r for r in rows if r["model"] == "base"), None)
|
| 117 |
+
if not base:
|
| 118 |
+
return "_No base row; cannot compute deltas._"
|
| 119 |
+
|
| 120 |
+
cand = []
|
| 121 |
+
for r in rows:
|
| 122 |
+
if r["model"] == "base":
|
| 123 |
+
continue
|
| 124 |
+
dq = r["quality"] - base["quality"]
|
| 125 |
+
der = r.get("eff_rank", 0.0) - base.get("eff_rank", 0.0)
|
| 126 |
+
dld = r.get("logdet", 0.0) - base.get("logdet", 0.0)
|
| 127 |
+
cand.append({"model": PRETTY.get(r["model"], r["model"]),
|
| 128 |
+
"raw": r["model"], "Δquality": round(dq, 3),
|
| 129 |
+
"Δeff_rank": round(der, 3), "Δlogdet": round(dld, 3),
|
| 130 |
+
"ends_cleanly": round(r.get("ends_cleanly", 0.0), 3),
|
| 131 |
+
"eligible": dq >= -QUALITY_TOLERANCE and r.get("ends_cleanly", 0) > 0.9})
|
| 132 |
+
|
| 133 |
+
ok = [c for c in cand if c["eligible"]]
|
| 134 |
+
ranked = sorted(ok, key=lambda c: -c["Δeff_rank"])
|
| 135 |
+
|
| 136 |
+
if not ranked:
|
| 137 |
+
return (f"**No arm qualifies.** Every arm either lost more than "
|
| 138 |
+
f"{QUALITY_TOLERANCE} judge points against base or fell below a "
|
| 139 |
+
f"90% clean-completion rate. The honest recommendation is to fix "
|
| 140 |
+
f"the objective before scaling anything to 8B.\n\n"
|
| 141 |
+
+ logbook.table(cand, ["model", "Δquality", "Δeff_rank",
|
| 142 |
+
"Δlogdet", "ends_cleanly", "eligible"]))
|
| 143 |
+
|
| 144 |
+
win = ranked[0]
|
| 145 |
+
runner = ranked[1] if len(ranked) > 1 else None
|
| 146 |
+
margin = (win["Δeff_rank"] - runner["Δeff_rank"]) if runner else None
|
| 147 |
+
close = margin is not None and margin < 0.25
|
| 148 |
+
|
| 149 |
+
txt = (f"**Scale `{win['model']}` to Qwen3-8B.** It gains "
|
| 150 |
+
f"{win['Δeff_rank']:+.2f} effective rank and {win['Δlogdet']:+.2f} "
|
| 151 |
+
f"log-det against base while holding quality at "
|
| 152 |
+
f"{win['Δquality']:+.2f} and completion at "
|
| 153 |
+
f"{100*win['ends_cleanly']:.0f}%.\n\n")
|
| 154 |
+
if runner:
|
| 155 |
+
txt += (f"Runner-up is `{runner['model']}` "
|
| 156 |
+
f"({runner['Δeff_rank']:+.2f} effective rank). ")
|
| 157 |
+
txt += ("The margin is **{:.2f}, which is small enough that this "
|
| 158 |
+
"ordering should not be treated as settled** on 50 eval prompts "
|
| 159 |
+
"— both are worth carrying forward.\n\n".format(margin)
|
| 160 |
+
if close else
|
| 161 |
+
"The margin ({:.2f}) is clear.\n\n".format(margin))
|
| 162 |
+
excluded = [c for c in cand if not c["eligible"]]
|
| 163 |
+
if excluded:
|
| 164 |
+
txt += ("Excluded for quality or completeness regression: "
|
| 165 |
+
+ ", ".join(f"`{c['model']}` (Δq {c['Δquality']:+.2f}, "
|
| 166 |
+
f"complete {100*c['ends_cleanly']:.0f}%)"
|
| 167 |
+
for c in excluded) + ".\n\n")
|
| 168 |
+
txt += logbook.table(cand, ["model", "Δquality", "Δeff_rank", "Δlogdet",
|
| 169 |
+
"ends_cleanly", "eligible"])
|
| 170 |
+
return txt
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def main():
|
| 174 |
+
ap = argparse.ArgumentParser()
|
| 175 |
+
ap.add_argument("--results", default="outputs/eval/results.csv")
|
| 176 |
+
ap.add_argument("--samples", default="outputs/eval/samples")
|
| 177 |
+
args = ap.parse_args()
|
| 178 |
+
|
| 179 |
+
rows = load(ROOT / args.results)
|
| 180 |
+
fig = logbook.FIGS / "frontier.png"
|
| 181 |
+
fig.parent.mkdir(parents=True, exist_ok=True)
|
| 182 |
+
frontier_chart(rows, fig)
|
| 183 |
+
|
| 184 |
+
main_cols = ["model", "quality", "ends_cleanly", "pairwise", "logdet",
|
| 185 |
+
"distinct4", "self_bleu", "eff_rank", "n_clusters", "tok_entropy", "words"]
|
| 186 |
+
tbl = [{k: (PRETTY.get(r["model"], r["model"]) if k == "model"
|
| 187 |
+
else round(r.get(k, 0.0), 4)) for k in main_cols} for r in rows]
|
| 188 |
+
|
| 189 |
+
# qualitative examples
|
| 190 |
+
qual = ""
|
| 191 |
+
sdir = ROOT / args.samples
|
| 192 |
+
for r in rows:
|
| 193 |
+
p = sdir / f"{r['model']}_examples.json"
|
| 194 |
+
if not p.exists():
|
| 195 |
+
continue
|
| 196 |
+
ex = json.load(open(p))
|
| 197 |
+
if not ex:
|
| 198 |
+
continue
|
| 199 |
+
e = ex[0]
|
| 200 |
+
qual += f"\n### {PRETTY.get(r['model'], r['model'])}\n\n"
|
| 201 |
+
qual += f"*Prompt:* {e['prompt'][:200]}\n\n"
|
| 202 |
+
qual += (f"*Set stats:* pairwise {e['pairwise']:.3f} · logdet {e['logdet']:.2f} "
|
| 203 |
+
f"· eff_rank {e['eff_rank']:.2f} · clusters {e['n_clusters']} "
|
| 204 |
+
f"· self-BLEU {e['self_bleu']:.3f}\n\n")
|
| 205 |
+
for i, t in enumerate(e["texts"][:3]):
|
| 206 |
+
first = t.strip().split("\n")[0][:180]
|
| 207 |
+
qual += f"{i+1}. {first}…\n"
|
| 208 |
+
body = f"""# Results — diversity-aware post-training for creative story generation
|
| 209 |
+
|
| 210 |
+
## Main table
|
| 211 |
+
|
| 212 |
+
{logbook.table(tbl, main_cols)}
|
| 213 |
+
|
| 214 |
+
`self_bleu` is inverted in meaning: **lower is more diverse**. `tok_entropy` is
|
| 215 |
+
monitor-only and was never optimized.
|
| 216 |
+
|
| 217 |
+
## Change vs. base model
|
| 218 |
+
|
| 219 |
+
{logbook.table(delta_table(rows))}
|
| 220 |
+
|
| 221 |
+
## Quality–diversity frontier
|
| 222 |
+
|
| 223 |
+

|
| 224 |
+
|
| 225 |
+
## Recommendation — which method to scale to Qwen3-8B
|
| 226 |
+
|
| 227 |
+
Selection rule, fixed before results were seen: among arms whose judge quality
|
| 228 |
+
is within {QUALITY_TOLERANCE} points of the **base** model and whose clean-completion
|
| 229 |
+
rate stays above 90%, take the largest gain in **effective rank**. Quality is
|
| 230 |
+
measured against base rather than against the best arm because the goal is to
|
| 231 |
+
add diversity without degrading the model, not to win on quality. Effective rank
|
| 232 |
+
is the primary diversity axis because it is the one mode metric demonstrated to
|
| 233 |
+
separate collapse from spread (`02_setup_and_deviations.md`, §11).
|
| 234 |
+
|
| 235 |
+
{recommend(rows)}
|
| 236 |
+
|
| 237 |
+
## Qualitative examples (first 3 of 16 samples, same prompt)
|
| 238 |
+
{qual}
|
| 239 |
+
"""
|
| 240 |
+
(logbook.LOGS.parent / "report.md").write_text(body)
|
| 241 |
+
print(body[:2500])
|
| 242 |
+
print("\nwrote report.md and", fig)
|
| 243 |
+
return 0
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
if __name__ == "__main__":
|
| 247 |
+
sys.exit(main())
|
src/qualitative.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Qualitative diff across arms: actually READ the stories.
|
| 3 |
+
|
| 4 |
+
Aggregate metrics can say "effective rank 2.0/16" without conveying that six of
|
| 5 |
+
the sixteen ships are literally named *Aethel*. This module extracts the
|
| 6 |
+
concrete signature of collapse -- shared openings, repeated proper nouns,
|
| 7 |
+
shared closing cadence, tone/genre spread -- so a human can see what changed.
|
| 8 |
+
|
| 9 |
+
Works on either a scored pool (`--pool`) or an eval dump (`--eval`), so the
|
| 10 |
+
base model, every checkpoint and every arm are read the same way.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import re
|
| 17 |
+
import sys
|
| 18 |
+
from collections import Counter, defaultdict
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 24 |
+
|
| 25 |
+
OPEN_FRAME = re.compile(r"^The\s+\*?([A-Z][\w' ]{2,20})\*?\s+(\w+ed|\w+s)\s+"
|
| 26 |
+
r"(through|over|above|at|into|across)\b")
|
| 27 |
+
ITALIC_NAME = re.compile(r"\*([A-Z][\w' ]{2,24})\*")
|
| 28 |
+
CADENCE = [
|
| 29 |
+
(r"for the first time in (centuries|years|generations|decades|a long time)",
|
| 30 |
+
"'for the first time in X'"),
|
| 31 |
+
(r"\b(had )?begun to\b", "'begun to'"),
|
| 32 |
+
(r"\bwasn'?t\s+\w+[^.]{0,50},?\s+it\s+was\b", "'it wasn't X, it was Y'"),
|
| 33 |
+
(r"\n\n[^\n]{1,60}\.\s*$", "one-line closing paragraph"),
|
| 34 |
+
]
|
| 35 |
+
# crude genre/register probes -- presence of ANY is weak, but the SPREAD across
|
| 36 |
+
# a 16-sample set is the interesting number
|
| 37 |
+
REGISTER = {
|
| 38 |
+
"elegiac/literary": r"\b(silence|ache|hollow|dust|memory|grief|quiet)\b",
|
| 39 |
+
"comic": r"\b(ridiculous|absurd|joke|laughed|idiot|bureaucra|paperwork)\b",
|
| 40 |
+
"horror": r"\b(scream|blood|rot|teeth|corpse|terror|wrong)\b",
|
| 41 |
+
"technical/SF": r"\b(protocol|sensor|reactor|alloy|deploy|calibrat|drone)\b",
|
| 42 |
+
"dialogue-driven": r'"[^"]{10,}"',
|
| 43 |
+
"second person": r"\bYou (are|were|walk|feel|stand|know)\b",
|
| 44 |
+
"epistolary": r"\b(Dear |entry |log:|Report |memo)\b",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def first_sentence(t: str, n: int = 130) -> str:
|
| 49 |
+
return re.split(r"(?<=[.!?])\s", t.strip())[0][:n]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def last_sentence(t: str, n: int = 120) -> str:
|
| 53 |
+
return re.split(r"(?<=[.!?])\s", t.strip())[-1][:n]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def analyze_group(texts: list[str]) -> dict:
|
| 57 |
+
n = len(texts)
|
| 58 |
+
names = Counter(x for t in texts for x in ITALIC_NAME.findall(t))
|
| 59 |
+
opens_the = sum(1 for t in texts if t.strip().startswith("The "))
|
| 60 |
+
frame = sum(1 for t in texts if OPEN_FRAME.match(t.strip()))
|
| 61 |
+
# repeated content words across stories (excluding prompt-driven ones is
|
| 62 |
+
# impossible in general, so we report the top shared nouns as-is)
|
| 63 |
+
stop = set("the a an and or but of to in on it its was were is are that this "
|
| 64 |
+
"with for as at by from had have has been be he she they we you i "
|
| 65 |
+
"his her their our my not no so then than there here what when "
|
| 66 |
+
"which who all more most into over under out up down".split())
|
| 67 |
+
per_story_vocab = [set(w for w in re.sub(r"[^a-z\s]", " ", t.lower()).split()
|
| 68 |
+
if w not in stop and len(w) > 3) for t in texts]
|
| 69 |
+
shared = Counter(w for v in per_story_vocab for w in v)
|
| 70 |
+
ubiquitous = {w: c for w, c in shared.most_common(400) if c >= max(3, int(0.6 * n))}
|
| 71 |
+
cad = {}
|
| 72 |
+
for pat, label in CADENCE:
|
| 73 |
+
cad[label] = sum(1 for t in texts if re.search(pat, t, re.I | re.M))
|
| 74 |
+
reg = {}
|
| 75 |
+
for label, pat in REGISTER.items():
|
| 76 |
+
reg[label] = sum(1 for t in texts if re.search(pat, t, re.I))
|
| 77 |
+
return {
|
| 78 |
+
"n": n,
|
| 79 |
+
"opens_with_The": opens_the,
|
| 80 |
+
"opening_frame_match": frame,
|
| 81 |
+
"distinct_first_5_words": len({" ".join(t.strip().split()[:5]).lower() for t in texts}),
|
| 82 |
+
"top_names": dict(names.most_common(6)),
|
| 83 |
+
"max_name_reuse": max(names.values()) if names else 0,
|
| 84 |
+
"ubiquitous_words": dict(list(ubiquitous.items())[:12]),
|
| 85 |
+
"cadence": cad,
|
| 86 |
+
"register_spread": reg,
|
| 87 |
+
"registers_present": sum(1 for v in reg.values() if v >= max(2, int(0.15 * n))),
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def load_pool(tag: str, split: str, prompt_id: str | None):
|
| 92 |
+
rows = [json.loads(l) for l in
|
| 93 |
+
open(ROOT / "outputs" / f"pool_{tag}" / f"pool_{split}.jsonl") if l.strip()]
|
| 94 |
+
by = defaultdict(list)
|
| 95 |
+
for r in rows:
|
| 96 |
+
by[r["prompt_id"]].append(r)
|
| 97 |
+
if prompt_id:
|
| 98 |
+
return {prompt_id: by[prompt_id]}
|
| 99 |
+
return dict(by)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def load_eval(label: str, out: str):
|
| 103 |
+
p = ROOT / out / "samples" / f"{label}_full.json"
|
| 104 |
+
data = json.load(open(p))
|
| 105 |
+
return {d["prompt_id"]: [{"text": t, "prompt": d["prompt"]} for t in d["texts"]]
|
| 106 |
+
for d in data}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def main():
|
| 110 |
+
ap = argparse.ArgumentParser()
|
| 111 |
+
ap.add_argument("--pool", help="pool tag, e.g. 4b")
|
| 112 |
+
ap.add_argument("--eval", help="eval label, e.g. E0-baseline")
|
| 113 |
+
ap.add_argument("--out", default="outputs/eval")
|
| 114 |
+
ap.add_argument("--split", default="train")
|
| 115 |
+
ap.add_argument("--prompt-id", default=None)
|
| 116 |
+
ap.add_argument("--show", type=int, default=16, help="first sentences to print")
|
| 117 |
+
ap.add_argument("--limit-prompts", type=int, default=40)
|
| 118 |
+
args = ap.parse_args()
|
| 119 |
+
|
| 120 |
+
groups = load_pool(args.pool, args.split, args.prompt_id) if args.pool \
|
| 121 |
+
else load_eval(args.eval, args.out)
|
| 122 |
+
keys = list(groups)[: args.limit_prompts]
|
| 123 |
+
|
| 124 |
+
agg = defaultdict(list)
|
| 125 |
+
for pid in keys:
|
| 126 |
+
texts = [r["text"] for r in groups[pid]]
|
| 127 |
+
if len(texts) < 4:
|
| 128 |
+
continue
|
| 129 |
+
a = analyze_group(texts)
|
| 130 |
+
agg["opens_with_The"].append(a["opens_with_The"] / a["n"])
|
| 131 |
+
agg["opening_frame"].append(a["opening_frame_match"] / a["n"])
|
| 132 |
+
agg["distinct_openers"].append(a["distinct_first_5_words"] / a["n"])
|
| 133 |
+
agg["max_name_reuse"].append(a["max_name_reuse"])
|
| 134 |
+
agg["registers_present"].append(a["registers_present"])
|
| 135 |
+
for k, v in a["cadence"].items():
|
| 136 |
+
agg[f"cadence:{k}"].append(v / a["n"])
|
| 137 |
+
|
| 138 |
+
name = args.eval or f"pool-{args.pool}"
|
| 139 |
+
print(f"\n===== QUALITATIVE PROFILE: {name} ({len(keys)} prompts) =====")
|
| 140 |
+
for k, v in agg.items():
|
| 141 |
+
print(f" {k:38} {np.mean(v):.3f}")
|
| 142 |
+
|
| 143 |
+
pid = args.prompt_id or keys[0]
|
| 144 |
+
texts = [r["text"] for r in groups[pid]][: args.show]
|
| 145 |
+
print(f"\n--- prompt {pid} ---")
|
| 146 |
+
print(" ", groups[pid][0].get("prompt", "")[:220])
|
| 147 |
+
print("\nOPENINGS:")
|
| 148 |
+
for i, t in enumerate(texts):
|
| 149 |
+
print(f" {i+1:2d}. {first_sentence(t)}")
|
| 150 |
+
print("\nCLOSINGS:")
|
| 151 |
+
for i, t in enumerate(texts):
|
| 152 |
+
print(f" {i+1:2d}. ...{last_sentence(t)}")
|
| 153 |
+
return 0
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
if __name__ == "__main__":
|
| 157 |
+
sys.exit(main())
|
src/rewards.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reward channels for E0 / E1 / E2, built for TRL's `normalize_then_sum`.
|
| 3 |
+
|
| 4 |
+
AGGREGATION
|
| 5 |
+
-----------
|
| 6 |
+
GRPOConfig(multi_objective_aggregation="normalize_then_sum") z-scores every
|
| 7 |
+
reward function WITHIN its group before weighting and summing. That is exactly
|
| 8 |
+
the decoupled GDPO-style normalization the brief asks for, so we get it without
|
| 9 |
+
patching TRL.
|
| 10 |
+
|
| 11 |
+
It also changes what the weights mean: alpha and gamma multiply STANDARDIZED
|
| 12 |
+
channels, so alpha=0.5 reads as "half a standard deviation of diversity credit
|
| 13 |
+
per standard deviation of quality". No manual rescaling of d_i into the 0-10
|
| 14 |
+
judge range is needed or wanted.
|
| 15 |
+
|
| 16 |
+
GATING (why it is not simply "set it to 0")
|
| 17 |
+
-------------------------------------------
|
| 18 |
+
The brief says a gate failure should zero the total reward. Under per-channel
|
| 19 |
+
z-scoring, writing 0.0 into a channel does NOT mean "no credit" -- it means
|
| 20 |
+
"whatever 0.0 ranks as inside this group". That is fine for quality (range
|
| 21 |
+
[0,10], floor 0) and for deviation (range [0,2], floor 0), but it is actively
|
| 22 |
+
WRONG for the marginal contribution:
|
| 23 |
+
|
| 24 |
+
m_i = logdet(L) - logdet(L_-i) <= log(1+eps) ~ 0
|
| 25 |
+
|
| 26 |
+
m_i is always <= 0, so 0.0 is its CEILING. Gating a broken story to 0.0 in the
|
| 27 |
+
marginal channel would hand it the single highest diversity credit in the group
|
| 28 |
+
-- a reward-hacking channel we would have built ourselves.
|
| 29 |
+
|
| 30 |
+
So the uniform rule, applied to every diversity channel regardless of sign
|
| 31 |
+
convention: an ineligible sample is assigned the MINIMUM value among eligible
|
| 32 |
+
samples in its group. It can never out-rank a sample that earned its credit.
|
| 33 |
+
Combined with quality -> 0.0 (a hard floor in that channel), a gate-failed
|
| 34 |
+
story lands at the bottom of every channel it participates in.
|
| 35 |
+
|
| 36 |
+
CONSTANT-REWARD SAFETY
|
| 37 |
+
----------------------
|
| 38 |
+
If every sample in a group is gated, a channel goes constant; TRL's
|
| 39 |
+
(x - mean)/(std + 1e-4) then yields ~0 for all of them. That is the correct
|
| 40 |
+
outcome: a group with no valid samples carries no signal. It is not a crash and
|
| 41 |
+
not a NaN, but it IS worth logging, so `frac_groups_degenerate` is tracked.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
from __future__ import annotations
|
| 45 |
+
|
| 46 |
+
import statistics
|
| 47 |
+
from dataclasses import dataclass, field
|
| 48 |
+
|
| 49 |
+
import numpy as np
|
| 50 |
+
|
| 51 |
+
import gates as G
|
| 52 |
+
from diversity import l2_normalize, marginal_contributions, pairwise_deviation, zscore
|
| 53 |
+
|
| 54 |
+
# --------------------------------------------------------------- embeddings
|
| 55 |
+
_ENCODER = None
|
| 56 |
+
_EMB_MODEL = "BAAI/bge-base-en-v1.5"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def get_encoder(model_name: str = _EMB_MODEL, device: str | None = None):
|
| 60 |
+
"""Module-level singleton; ~110M params (~0.22GB), negligible next to the policy.
|
| 61 |
+
|
| 62 |
+
Device is overridable via EMB_DEVICE so tests (and any process running
|
| 63 |
+
alongside a training job that already owns the GPU) can force CPU.
|
| 64 |
+
"""
|
| 65 |
+
global _ENCODER
|
| 66 |
+
if _ENCODER is None:
|
| 67 |
+
import os
|
| 68 |
+
from sentence_transformers import SentenceTransformer
|
| 69 |
+
dev = device or os.environ.get("EMB_DEVICE", "cuda")
|
| 70 |
+
_ENCODER = SentenceTransformer(model_name, device=dev)
|
| 71 |
+
return _ENCODER
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def embed(texts: list[str]) -> np.ndarray:
|
| 75 |
+
if not texts:
|
| 76 |
+
return np.zeros((0, 768))
|
| 77 |
+
E = get_encoder().encode(
|
| 78 |
+
texts, normalize_embeddings=True, batch_size=32,
|
| 79 |
+
show_progress_bar=False, convert_to_numpy=True,
|
| 80 |
+
)
|
| 81 |
+
return l2_normalize(np.asarray(E, dtype=np.float64))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ------------------------------------------------------------------ config
|
| 85 |
+
@dataclass
|
| 86 |
+
class RewardConfig:
|
| 87 |
+
arm: str = "E0" # E0 | E1 | E2
|
| 88 |
+
alpha: float = 0.5 # weight on deviation channel
|
| 89 |
+
gamma: float = 0.5 # weight on marginal channel
|
| 90 |
+
tau: float = 5.0 # quality gate for diversity credit
|
| 91 |
+
min_words: int = G.MIN_WORDS
|
| 92 |
+
max_words: int = G.MAX_WORDS
|
| 93 |
+
|
| 94 |
+
def channels(self) -> list[str]:
|
| 95 |
+
if self.arm == "E0":
|
| 96 |
+
return ["quality"]
|
| 97 |
+
if self.arm == "E1":
|
| 98 |
+
return ["quality", "deviation"]
|
| 99 |
+
if self.arm == "E2":
|
| 100 |
+
return ["quality", "deviation", "marginal"]
|
| 101 |
+
raise ValueError(self.arm)
|
| 102 |
+
|
| 103 |
+
def weights(self) -> list[float]:
|
| 104 |
+
return {"E0": [1.0],
|
| 105 |
+
"E1": [1.0, self.alpha],
|
| 106 |
+
"E2": [1.0, self.alpha, self.gamma]}[self.arm]
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@dataclass
|
| 110 |
+
class StepStats:
|
| 111 |
+
n: int = 0
|
| 112 |
+
gate_pass: float = 0.0
|
| 113 |
+
ends_cleanly: float = 0.0
|
| 114 |
+
mean_quality: float = 0.0
|
| 115 |
+
mean_quality_passing: float = 0.0
|
| 116 |
+
mean_novelty: float = 0.0
|
| 117 |
+
frac_above_tau: float = 0.0
|
| 118 |
+
mean_deviation: float = 0.0
|
| 119 |
+
mean_logdet: float = 0.0
|
| 120 |
+
mean_marginal: float = 0.0
|
| 121 |
+
mean_words: float = 0.0
|
| 122 |
+
frac_groups_degenerate: float = 0.0
|
| 123 |
+
reasons: dict = field(default_factory=dict)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _gate_floor(values: np.ndarray, eligible: np.ndarray) -> np.ndarray:
|
| 127 |
+
"""Ineligible samples take the minimum value among eligible ones.
|
| 128 |
+
|
| 129 |
+
Sign-convention agnostic: works for deviation (>=0) and for marginal (<=0)
|
| 130 |
+
alike. If nothing is eligible, the channel is flat -> z-scores to 0 in TRL.
|
| 131 |
+
"""
|
| 132 |
+
out = values.astype(np.float64).copy()
|
| 133 |
+
if not eligible.any():
|
| 134 |
+
return np.zeros_like(out)
|
| 135 |
+
out[~eligible] = values[eligible].min()
|
| 136 |
+
return out
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class RewardEngine:
|
| 140 |
+
"""Scores one GRPO batch: gates -> judge -> embeddings -> per-channel values.
|
| 141 |
+
|
| 142 |
+
TRL calls each reward function separately, but we want ONE judge call set
|
| 143 |
+
and ONE embedding pass per batch. So the engine computes everything once and
|
| 144 |
+
memoizes on the batch signature; the per-channel closures just read it.
|
| 145 |
+
"""
|
| 146 |
+
|
| 147 |
+
def __init__(self, cfg: RewardConfig, judge, wandb_run=None, log_prefix="train"):
|
| 148 |
+
self.cfg = cfg
|
| 149 |
+
self.judge = judge
|
| 150 |
+
self.wandb_run = wandb_run
|
| 151 |
+
self.log_prefix = log_prefix
|
| 152 |
+
self._sig = None
|
| 153 |
+
self._cache: dict[str, np.ndarray] = {}
|
| 154 |
+
self.last_stats: StepStats | None = None
|
| 155 |
+
self.history: list[StepStats] = []
|
| 156 |
+
|
| 157 |
+
# ---- core ----------------------------------------------------------
|
| 158 |
+
def compute(self, prompts: list[str], texts: list[str],
|
| 159 |
+
finish_reasons: list[str] | None = None) -> dict[str, np.ndarray]:
|
| 160 |
+
sig = hash((tuple(prompts), tuple(texts)))
|
| 161 |
+
if sig == self._sig:
|
| 162 |
+
return self._cache
|
| 163 |
+
|
| 164 |
+
n = len(texts)
|
| 165 |
+
finish_reasons = finish_reasons or [None] * n
|
| 166 |
+
|
| 167 |
+
# 1. programmatic gates (free, run first)
|
| 168 |
+
gres = [G.check(t, finish_reason=fr, min_words=self.cfg.min_words,
|
| 169 |
+
max_words=self.cfg.max_words)
|
| 170 |
+
for t, fr in zip(texts, finish_reasons)]
|
| 171 |
+
passed = np.array([r.passed for r in gres], dtype=bool)
|
| 172 |
+
|
| 173 |
+
# 2. judge only the stories that survived the gates -- never pay to
|
| 174 |
+
# score text we have already decided to zero out.
|
| 175 |
+
quality = np.zeros(n); novelty = np.zeros(n)
|
| 176 |
+
idx = [i for i in range(n) if passed[i]]
|
| 177 |
+
if idx:
|
| 178 |
+
scores = self.judge.score_many_sync([(prompts[i], texts[i]) for i in idx])
|
| 179 |
+
for i, s in zip(idx, scores):
|
| 180 |
+
quality[i] = s.quality
|
| 181 |
+
novelty[i] = s.novelty
|
| 182 |
+
|
| 183 |
+
# 3. embeddings + per-group diversity
|
| 184 |
+
E = embed(texts)
|
| 185 |
+
groups: dict[str, list[int]] = {}
|
| 186 |
+
for i, p in enumerate(prompts):
|
| 187 |
+
groups.setdefault(p, []).append(i)
|
| 188 |
+
|
| 189 |
+
dev = np.zeros(n); marg = np.zeros(n)
|
| 190 |
+
logdets, degenerate = [], 0
|
| 191 |
+
for _, ids in groups.items():
|
| 192 |
+
sub = E[ids]
|
| 193 |
+
d = pairwise_deviation(sub)
|
| 194 |
+
m = marginal_contributions(sub)
|
| 195 |
+
# z-score m within group: raw m has a long negative tail (a duplicate
|
| 196 |
+
# pair reaches log(eps) ~ -6.9) that would otherwise dominate.
|
| 197 |
+
mz = zscore(m)
|
| 198 |
+
for k, i in enumerate(ids):
|
| 199 |
+
dev[i] = d[k]; marg[i] = mz[k]
|
| 200 |
+
from diversity import logdet_volume
|
| 201 |
+
logdets.append(logdet_volume(sub))
|
| 202 |
+
if not passed[ids].any():
|
| 203 |
+
degenerate += 1
|
| 204 |
+
|
| 205 |
+
# 4. eligibility for diversity credit: gates AND quality >= tau.
|
| 206 |
+
# Conditioning is what stops "incoherent but different" from paying.
|
| 207 |
+
eligible = passed & (quality >= self.cfg.tau)
|
| 208 |
+
|
| 209 |
+
dev_c = np.zeros(n); marg_c = np.zeros(n)
|
| 210 |
+
for _, ids in groups.items():
|
| 211 |
+
ids_a = np.array(ids)
|
| 212 |
+
dev_c[ids_a] = _gate_floor(dev[ids_a], eligible[ids_a])
|
| 213 |
+
marg_c[ids_a] = _gate_floor(marg[ids_a], eligible[ids_a])
|
| 214 |
+
|
| 215 |
+
quality_c = np.where(passed, quality, 0.0)
|
| 216 |
+
|
| 217 |
+
out = {"quality": quality_c, "deviation": dev_c, "marginal": marg_c}
|
| 218 |
+
self._sig, self._cache = sig, out
|
| 219 |
+
|
| 220 |
+
# ---- stats ----
|
| 221 |
+
from collections import Counter
|
| 222 |
+
cnt = Counter(r for x in gres for r in x.reasons)
|
| 223 |
+
st = StepStats(
|
| 224 |
+
n=n,
|
| 225 |
+
gate_pass=float(passed.mean()),
|
| 226 |
+
ends_cleanly=float(np.mean([r.completeness for r in gres])),
|
| 227 |
+
mean_quality=float(quality.mean()),
|
| 228 |
+
mean_quality_passing=float(quality[passed].mean()) if passed.any() else 0.0,
|
| 229 |
+
mean_novelty=float(novelty[passed].mean()) if passed.any() else 0.0,
|
| 230 |
+
frac_above_tau=float(eligible.mean()),
|
| 231 |
+
mean_deviation=float(dev.mean()),
|
| 232 |
+
mean_logdet=float(np.mean(logdets)) if logdets else 0.0,
|
| 233 |
+
mean_marginal=float(marg.mean()),
|
| 234 |
+
mean_words=float(np.mean([r.n_words for r in gres])),
|
| 235 |
+
frac_groups_degenerate=degenerate / max(1, len(groups)),
|
| 236 |
+
reasons=dict(cnt),
|
| 237 |
+
)
|
| 238 |
+
self.last_stats = st
|
| 239 |
+
self.history.append(st)
|
| 240 |
+
self._log(st)
|
| 241 |
+
return out
|
| 242 |
+
|
| 243 |
+
def _log(self, st: StepStats) -> None:
|
| 244 |
+
print(f" [rw] gate={st.gate_pass:.2f} end={st.ends_cleanly:.2f} "
|
| 245 |
+
f"q={st.mean_quality_passing:.2f} >tau={st.frac_above_tau:.2f} "
|
| 246 |
+
f"dev={st.mean_deviation:.3f} logdet={st.mean_logdet:.2f} "
|
| 247 |
+
f"w={st.mean_words:.0f} {st.reasons if st.reasons else ''}", flush=True)
|
| 248 |
+
if self.wandb_run is not None:
|
| 249 |
+
p = self.log_prefix
|
| 250 |
+
self.wandb_run.log({
|
| 251 |
+
f"{p}/gate_pass": st.gate_pass,
|
| 252 |
+
f"{p}/ends_cleanly": st.ends_cleanly,
|
| 253 |
+
f"{p}/quality_passing": st.mean_quality_passing,
|
| 254 |
+
f"{p}/quality_all": st.mean_quality,
|
| 255 |
+
f"{p}/novelty": st.mean_novelty,
|
| 256 |
+
f"{p}/frac_above_tau": st.frac_above_tau,
|
| 257 |
+
f"{p}/deviation": st.mean_deviation,
|
| 258 |
+
f"{p}/logdet": st.mean_logdet,
|
| 259 |
+
f"{p}/marginal_z": st.mean_marginal,
|
| 260 |
+
f"{p}/words": st.mean_words,
|
| 261 |
+
f"{p}/groups_degenerate": st.frac_groups_degenerate,
|
| 262 |
+
})
|
| 263 |
+
|
| 264 |
+
# ---- TRL adapters ---------------------------------------------------
|
| 265 |
+
def make_reward_funcs(self):
|
| 266 |
+
"""Return TRL-compatible reward callables, one per active channel."""
|
| 267 |
+
funcs = []
|
| 268 |
+
for ch in self.cfg.channels():
|
| 269 |
+
funcs.append(self._make(ch))
|
| 270 |
+
return funcs
|
| 271 |
+
|
| 272 |
+
def _make(self, channel: str):
|
| 273 |
+
engine = self
|
| 274 |
+
|
| 275 |
+
def f(completions, prompts=None, **kwargs):
|
| 276 |
+
texts = [_text(c) for c in completions]
|
| 277 |
+
ps = [_ptext(p) for p in (prompts or [""] * len(texts))]
|
| 278 |
+
fr = kwargs.get("finish_reasons")
|
| 279 |
+
vals = engine.compute(ps, texts, fr)
|
| 280 |
+
return [float(x) for x in vals[channel]]
|
| 281 |
+
|
| 282 |
+
f.__name__ = f"{channel}_reward"
|
| 283 |
+
return f
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _text(c) -> str:
|
| 287 |
+
if isinstance(c, list):
|
| 288 |
+
return c[-1].get("content", "") if c else ""
|
| 289 |
+
if isinstance(c, dict):
|
| 290 |
+
return c.get("content", "")
|
| 291 |
+
return str(c)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _ptext(p) -> str:
|
| 295 |
+
if isinstance(p, list):
|
| 296 |
+
# chat format: the user turn carries the writing prompt
|
| 297 |
+
for m in reversed(p):
|
| 298 |
+
if isinstance(m, dict) and m.get("role") == "user":
|
| 299 |
+
return m.get("content", "")
|
| 300 |
+
return p[-1].get("content", "") if p else ""
|
| 301 |
+
return str(p)
|
src/smoke_vllm.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke test: vLLM on Blackwell + Qwen3-4B + new system prompt + gates.
|
| 2 |
+
|
| 3 |
+
The specific question: does the rewritten system prompt (200-500 word budget,
|
| 4 |
+
explicit ending instruction) plus max_new_tokens=768 actually fix the 100%
|
| 5 |
+
truncation rate that killed the prior run? Prints the gate pass rate.
|
| 6 |
+
"""
|
| 7 |
+
import json
|
| 8 |
+
import sys
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from transformers import AutoTokenizer
|
| 13 |
+
|
| 14 |
+
import gates
|
| 15 |
+
from data import load_prompts
|
| 16 |
+
from generate import build_llm, generate, save_gens
|
| 17 |
+
|
| 18 |
+
MODEL = "Qwen/Qwen3-4B-Instruct-2507"
|
| 19 |
+
N_PROMPTS = 8
|
| 20 |
+
G = 8
|
| 21 |
+
|
| 22 |
+
def main():
|
| 23 |
+
t0 = time.time()
|
| 24 |
+
tok = AutoTokenizer.from_pretrained(MODEL)
|
| 25 |
+
llm = build_llm(MODEL, gpu_mem_util=0.85, seed=0)
|
| 26 |
+
print(f"[load] {time.time()-t0:.1f}s", flush=True)
|
| 27 |
+
|
| 28 |
+
prompts = load_prompts("eval")[:N_PROMPTS]
|
| 29 |
+
t1 = time.time()
|
| 30 |
+
gens = generate(llm, tok, prompts, n=G, temperature=1.0, top_p=1.0, seed=1234)
|
| 31 |
+
dt = time.time() - t1
|
| 32 |
+
ntok = sum(g.n_tokens for g in gens)
|
| 33 |
+
print(f"[gen] {len(gens)} stories, {ntok} tok in {dt:.1f}s -> {ntok/dt:.0f} tok/s", flush=True)
|
| 34 |
+
|
| 35 |
+
res = [gates.check(g.text, finish_reason=g.finish_reason) for g in gens]
|
| 36 |
+
passed = sum(r.passed for r in res)
|
| 37 |
+
complete = sum(r.completeness for r in res)
|
| 38 |
+
capped = sum(r.hit_token_cap for r in res)
|
| 39 |
+
words = np.array([r.n_words for r in res])
|
| 40 |
+
|
| 41 |
+
print("\n=== GATES ===")
|
| 42 |
+
print(f" pass_all_gates {passed}/{len(res)} ({100*passed/len(res):.0f}%)")
|
| 43 |
+
print(f" ends_cleanly {complete:.0f}/{len(res)} ({100*complete/len(res):.0f}%) <- prior run: 0-8%")
|
| 44 |
+
print(f" hit_token_cap {capped}/{len(res)}")
|
| 45 |
+
print(f" words p5={np.percentile(words,5):.0f} med={np.median(words):.0f} p95={np.percentile(words,95):.0f} max={words.max()}")
|
| 46 |
+
from collections import Counter
|
| 47 |
+
c = Counter(r for x in res for r in x.reasons)
|
| 48 |
+
print(f" failure reasons {dict(c)}")
|
| 49 |
+
print(f" mean_logprob {np.mean([g.mean_logprob for g in gens]):.3f}")
|
| 50 |
+
|
| 51 |
+
save_gens(gens, "outputs/smoke/vllm_smoke.jsonl")
|
| 52 |
+
print("\n--- sample tail (last 90 chars each, first 4) ---")
|
| 53 |
+
for g in gens[:4]:
|
| 54 |
+
print(" ...", repr(g.text[-90:]))
|
| 55 |
+
print(f"\n[total] {time.time()-t0:.1f}s")
|
| 56 |
+
return 0 if complete / len(res) > 0.8 else 1
|
| 57 |
+
|
| 58 |
+
if __name__ == "__main__":
|
| 59 |
+
sys.exit(main())
|
src/status.sh
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# User is in IST (UTC+5:30) -- report wall-clock in IST, not UTC.
|
| 3 |
+
export TZ="Asia/Kolkata"
|
| 4 |
+
# One-line-ish status snapshot for periodic reporting.
|
| 5 |
+
cd /workspace/creative-writing
|
| 6 |
+
STAGE=$(grep -aE "^== (START|OK|FAIL|ABORT)" logs/pipeline.log 2>/dev/null | tail -1 | sed 's/ */ /g')
|
| 7 |
+
|
| 8 |
+
# active training log (most recently modified)
|
| 9 |
+
# only consider logs of a CURRENTLY RUNNING job; otherwise a finished
|
| 10 |
+
# stage's log masquerades as live status (this bit me once already).
|
| 11 |
+
RUNCFG=$(pgrep -af "src/train_(grpo|dpo).py" | grep -oE "configs/[^ ]+\.yaml" | head -1)
|
| 12 |
+
ACT=""
|
| 13 |
+
if [ -n "$RUNCFG" ]; then
|
| 14 |
+
NM=$(python3 -c "import yaml,sys;print(yaml.safe_load(open(sys.argv[1]))['name'])" "$RUNCFG" 2>/dev/null)
|
| 15 |
+
for c in "logs/train_${NM}.log" "logs/lrprobe_3e-5.log" $(ls -t logs/*.log 2>/dev/null); do
|
| 16 |
+
[ -f "$c" ] && ACT="$c" && break
|
| 17 |
+
done
|
| 18 |
+
fi
|
| 19 |
+
PROG=""; RW=""; ENT=""
|
| 20 |
+
if [ -n "$ACT" ]; then
|
| 21 |
+
PROG=$(tr '\r' '\n' < "$ACT" | grep -aE "^ *[0-9]+%\|" | tail -1 | sed 's/ */ /g')
|
| 22 |
+
RW=$(grep -ao "\[rw\].*" "$ACT" 2>/dev/null | tail -1)
|
| 23 |
+
ENT=$(grep -aoE "'entropy': '[^']*'" "$ACT" 2>/dev/null | tail -1)
|
| 24 |
+
fi
|
| 25 |
+
|
| 26 |
+
VRAM=$(nvidia-smi --query-gpu=memory.used,utilization.gpu --format=csv,noheader,nounits | tr ',' '/' | tr -d ' ')
|
| 27 |
+
DISK=$(df -BG --output=avail /workspace | tail -1 | tr -d ' ')
|
| 28 |
+
SPEND=$(python3 -c "
|
| 29 |
+
import os,urllib.request,json
|
| 30 |
+
try:
|
| 31 |
+
r=urllib.request.Request('https://openrouter.ai/api/v1/key',headers={'Authorization':'Bearer '+os.environ['OPENROUTER_API_KEY']})
|
| 32 |
+
d=json.load(urllib.request.urlopen(r,timeout=20))['data']
|
| 33 |
+
print(f\"\${d['usage']:.2f}used/\${d['limit_remaining']:.2f}left\")
|
| 34 |
+
except Exception: print('n/a')" 2>/dev/null)
|
| 35 |
+
|
| 36 |
+
ERR=$(grep -alE "OutOfMemory|Traceback|UNHEALTHY|GUARDRAIL" logs/train_*.log logs/E[34]*.log logs/eval_all.log 2>/dev/null | tr '\n' ',' )
|
| 37 |
+
|
| 38 |
+
NOW=$(TZ=Asia/Kolkata date +"%H:%M IST")
|
| 39 |
+
echo "[$NOW] STAGE=[$STAGE] PROG=[$PROG] ${RW:+RW=[$RW] }${ENT:+$ENT }vram=${VRAM} disk=${DISK} judge=${SPEND}${ERR:+ ERRORS_IN=$ERR}"
|
src/test_diversity.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for src/diversity.py. Run: python3 -m pytest src/test_diversity.py -q
|
| 3 |
+
or: python3 src/test_diversity.py
|
| 4 |
+
"""
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from diversity import (
|
| 8 |
+
cosine_kernel,
|
| 9 |
+
greedy_diverse_subset,
|
| 10 |
+
l2_normalize,
|
| 11 |
+
logdet_volume,
|
| 12 |
+
marginal_contributions,
|
| 13 |
+
pairwise_deviation,
|
| 14 |
+
zscore,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
RNG = np.random.default_rng(0)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _orth(G, d):
|
| 21 |
+
"""G mutually orthonormal rows in R^d."""
|
| 22 |
+
Q, _ = np.linalg.qr(RNG.standard_normal((d, G)))
|
| 23 |
+
return Q.T[:G]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ---------------------------------------------------------------- deviation
|
| 27 |
+
def test_deviation_duplicates_are_zero():
|
| 28 |
+
"""Exact duplicates: every pair distance is 0, so d_i == 0 for all."""
|
| 29 |
+
e = l2_normalize(RNG.standard_normal((1, 32)))
|
| 30 |
+
E = np.repeat(e, 8, axis=0)
|
| 31 |
+
d = pairwise_deviation(E)
|
| 32 |
+
assert np.allclose(d, 0.0, atol=1e-9), d
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_deviation_orthogonal_is_one():
|
| 36 |
+
"""Orthogonal rows: cos = 0 for every pair, so d_i == 1 exactly."""
|
| 37 |
+
E = _orth(8, 64)
|
| 38 |
+
d = pairwise_deviation(E)
|
| 39 |
+
assert np.allclose(d, 1.0, atol=1e-9), d
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_deviation_singleton_and_empty():
|
| 43 |
+
assert pairwise_deviation(np.zeros((1, 8))).shape == (1,)
|
| 44 |
+
assert pairwise_deviation(np.zeros((1, 8)))[0] == 0.0
|
| 45 |
+
assert pairwise_deviation(np.zeros((0, 8))).shape == (0,)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_deviation_flags_the_odd_one_out():
|
| 49 |
+
"""7 near-identical + 1 far: the outlier must have the highest d_i."""
|
| 50 |
+
base = l2_normalize(RNG.standard_normal((1, 64)))
|
| 51 |
+
tight = l2_normalize(np.repeat(base, 7, axis=0) + 0.01 * RNG.standard_normal((7, 64)))
|
| 52 |
+
far = l2_normalize(RNG.standard_normal((1, 64)))
|
| 53 |
+
E = np.vstack([tight, far])
|
| 54 |
+
d = pairwise_deviation(E)
|
| 55 |
+
assert d.argmax() == 7, d
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ------------------------------------------------------- marginal / logdet
|
| 59 |
+
def test_marginal_duplicate_is_large_negative():
|
| 60 |
+
"""A duplicated direction is already spanned -> dropping one costs ~nothing,
|
| 61 |
+
so the PRESENT duplicate's marginal is driven to ~log(eps), very negative."""
|
| 62 |
+
e = l2_normalize(RNG.standard_normal((1, 32)))
|
| 63 |
+
E = np.vstack([np.repeat(e, 2, axis=0), _orth(4, 32)])
|
| 64 |
+
m = marginal_contributions(E)
|
| 65 |
+
# the two duplicates (rows 0,1) are the least valuable members
|
| 66 |
+
assert m[0] < -3.0 and m[1] < -3.0, m
|
| 67 |
+
assert m[:2].max() < m[2:].min(), m
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_marginal_orthogonal_is_near_zero_and_uniform():
|
| 71 |
+
"""Orthonormal rows: L = (1+eps)I, dropping any row costs log(1+eps) ~ 0.
|
| 72 |
+
'High m_i for all' in the sense of at-ceiling and symmetric."""
|
| 73 |
+
E = _orth(8, 64)
|
| 74 |
+
m = marginal_contributions(E)
|
| 75 |
+
assert np.allclose(m, m[0], atol=1e-9), m
|
| 76 |
+
assert abs(m[0] - np.log1p(1e-3)) < 1e-6, m[0]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_marginal_is_bounded_above_by_zero_ish():
|
| 80 |
+
"""logdet is monotone under adding a row with unit norm + jitter, so
|
| 81 |
+
m_i can never exceed log(1+eps)."""
|
| 82 |
+
E = l2_normalize(RNG.standard_normal((12, 64)))
|
| 83 |
+
m = marginal_contributions(E)
|
| 84 |
+
assert m.max() <= np.log1p(1e-3) + 1e-9, m.max()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_logdet_ordering_dup_lt_spread_lt_orthogonal():
|
| 88 |
+
e = l2_normalize(RNG.standard_normal((1, 64)))
|
| 89 |
+
dup = np.repeat(e, 8, axis=0)
|
| 90 |
+
spread = l2_normalize(RNG.standard_normal((8, 64)))
|
| 91 |
+
orth = _orth(8, 64)
|
| 92 |
+
assert logdet_volume(dup) < logdet_volume(spread) < logdet_volume(orth)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_kernel_is_psd_even_with_duplicates():
|
| 96 |
+
e = l2_normalize(RNG.standard_normal((1, 16)))
|
| 97 |
+
L = cosine_kernel(np.repeat(e, 6, axis=0))
|
| 98 |
+
assert np.linalg.eigvalsh(L).min() > 0, "jitter failed to make L PD"
|
| 99 |
+
assert np.isfinite(logdet_volume(np.repeat(e, 6, axis=0)))
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# --------------------------------------- THE E1-vs-E2 HYPOTHESIS, AS A TEST
|
| 103 |
+
def test_two_clusters_fool_deviation_but_not_logdet():
|
| 104 |
+
"""This is the claim E2 rests on, so it gets asserted rather than assumed.
|
| 105 |
+
|
| 106 |
+
Config A: two tight antipodal clusters of 4 (rank ~2, 'diverse' only in
|
| 107 |
+
the sense that half the samples are far from the other half).
|
| 108 |
+
Config B: 8 genuinely spread directions (rank ~8).
|
| 109 |
+
|
| 110 |
+
Pairwise deviation cannot tell these apart -- mean pairwise distance for A
|
| 111 |
+
is actually HIGHER, because antipodal pairs sit at cos = -1. Log-det sees
|
| 112 |
+
straight through it: A occupies a 2-dimensional subspace.
|
| 113 |
+
"""
|
| 114 |
+
u, v = _orth(2, 64)
|
| 115 |
+
jit = 0.01
|
| 116 |
+
A = l2_normalize(np.vstack([
|
| 117 |
+
np.repeat(u[None], 4, axis=0) + jit * RNG.standard_normal((4, 64)),
|
| 118 |
+
np.repeat(-u[None], 4, axis=0) + jit * RNG.standard_normal((4, 64)),
|
| 119 |
+
]))
|
| 120 |
+
B = _orth(8, 64)
|
| 121 |
+
|
| 122 |
+
dev_A, dev_B = pairwise_deviation(A).mean(), pairwise_deviation(B).mean()
|
| 123 |
+
vol_A, vol_B = logdet_volume(A), logdet_volume(B)
|
| 124 |
+
|
| 125 |
+
# deviation RANKS THE DEGENERATE SET HIGHER -- the failure mode, reproduced
|
| 126 |
+
assert dev_A > dev_B, (dev_A, dev_B)
|
| 127 |
+
# log-det correctly ranks the spread set far higher
|
| 128 |
+
assert vol_B > vol_A + 10.0, (vol_A, vol_B)
|
| 129 |
+
|
| 130 |
+
# and per-sample: in A every member is redundant (its twin covers it),
|
| 131 |
+
# so marginal contributions are uniformly terrible
|
| 132 |
+
assert marginal_contributions(A).max() < -3.0
|
| 133 |
+
assert marginal_contributions(B).min() > -1e-3
|
| 134 |
+
_ = v # second basis vector unused, kept for clarity of construction
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ------------------------------------------------------------------ zscore
|
| 138 |
+
def test_zscore_constant_input_is_zeros_not_nan():
|
| 139 |
+
"""A constant reward column must degrade to 0, never NaN -- otherwise it
|
| 140 |
+
poisons the whole advantage tensor."""
|
| 141 |
+
z = zscore(np.full(8, 3.7))
|
| 142 |
+
assert np.all(np.isfinite(z)) and np.allclose(z, 0.0)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_zscore_standardizes():
|
| 146 |
+
z = zscore(RNG.standard_normal(64))
|
| 147 |
+
assert abs(z.mean()) < 1e-9 and abs(z.std() - 1.0) < 1e-9
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ------------------------------------------------------------ greedy subset
|
| 151 |
+
def test_greedy_avoids_duplicates_when_quality_is_flat():
|
| 152 |
+
"""With flat quality, selection is pure logdet: must not pick both dupes."""
|
| 153 |
+
e = l2_normalize(RNG.standard_normal((1, 64)))
|
| 154 |
+
E = np.vstack([np.repeat(e, 3, axis=0), _orth(3, 64)]) # rows 0,1,2 identical
|
| 155 |
+
sel = greedy_diverse_subset(np.ones(6), E, k=3, lam=1.0)
|
| 156 |
+
assert len(sel) == 3
|
| 157 |
+
assert len(set(sel) & {0, 1, 2}) <= 1, sel
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def test_greedy_respects_quality_when_lambda_is_zero():
|
| 161 |
+
E = l2_normalize(RNG.standard_normal((10, 64)))
|
| 162 |
+
q = np.arange(10, dtype=float)
|
| 163 |
+
sel = greedy_diverse_subset(q, E, k=3, lam=0.0)
|
| 164 |
+
assert sorted(sel) == [7, 8, 9], sel
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_greedy_k_larger_than_pool():
|
| 168 |
+
E = l2_normalize(RNG.standard_normal((3, 32)))
|
| 169 |
+
assert len(greedy_diverse_subset(np.ones(3), E, k=8)) == 3
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
if __name__ == "__main__":
|
| 173 |
+
import sys, traceback
|
| 174 |
+
fns = [(n, f) for n, f in sorted(globals().items())
|
| 175 |
+
if n.startswith("test_") and callable(f)]
|
| 176 |
+
bad = 0
|
| 177 |
+
for n, f in fns:
|
| 178 |
+
try:
|
| 179 |
+
f()
|
| 180 |
+
print(f" PASS {n}")
|
| 181 |
+
except Exception:
|
| 182 |
+
bad += 1
|
| 183 |
+
print(f" FAIL {n}")
|
| 184 |
+
traceback.print_exc()
|
| 185 |
+
print(f"\n{len(fns)-bad}/{len(fns)} passed")
|
| 186 |
+
sys.exit(1 if bad else 0)
|
src/test_eval_metrics.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for evaluate.py's pure metric functions. These run LAST in the pipeline,
|
| 3 |
+
so a bug here would waste every GPU-hour before it.
|
| 4 |
+
|
| 5 |
+
Directionality is the thing being pinned down: self-BLEU is inverted relative to
|
| 6 |
+
every other diversity metric (lower = more diverse), and getting that backwards
|
| 7 |
+
in the report would invert the study's conclusion.
|
| 8 |
+
"""
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from diversity import effective_rank
|
| 12 |
+
from evaluate import cluster_count, distinct_n, self_bleu, topk_entropy
|
| 13 |
+
|
| 14 |
+
IDENTICAL = ["The harbor clock struck twelve and the ferry did not come."] * 8
|
| 15 |
+
VARIED = [
|
| 16 |
+
"The harbor clock struck twelve and the ferry did not come.",
|
| 17 |
+
"Marguerite sold her mother's piano to pay for the greenhouse.",
|
| 18 |
+
"In the third week of the drought, the well began speaking Latin.",
|
| 19 |
+
"He counted seventeen crows before admitting he was being followed.",
|
| 20 |
+
"The recipe called for one tablespoon of regret, finely minced.",
|
| 21 |
+
"Every letter she mailed arrived a decade before she wrote it.",
|
| 22 |
+
"Nobody warned the astronauts that the moon would be so loud.",
|
| 23 |
+
"My grandfather traded his shadow for a working knowledge of bees.",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_distinct4_identical_is_low():
|
| 28 |
+
"""8 copies of one sentence: only 1/8 of 4-grams are new."""
|
| 29 |
+
d = distinct_n(IDENTICAL, 4)
|
| 30 |
+
assert d < 0.2, d
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_distinct4_varied_is_high():
|
| 34 |
+
assert distinct_n(VARIED, 4) > 0.9
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_distinct4_ordering():
|
| 38 |
+
assert distinct_n(VARIED, 4) > distinct_n(IDENTICAL, 4)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_self_bleu_is_inverted_lower_means_more_diverse():
|
| 42 |
+
"""THE directionality check. Identical texts must score HIGH self-BLEU."""
|
| 43 |
+
hi = self_bleu(IDENTICAL)
|
| 44 |
+
lo = self_bleu(VARIED)
|
| 45 |
+
assert hi > lo, f"self-BLEU not inverted: identical={hi:.4f} varied={lo:.4f}"
|
| 46 |
+
assert hi > 0.5, f"identical texts should have high self-BLEU, got {hi:.4f}"
|
| 47 |
+
assert lo < 0.1, f"varied texts should have low self-BLEU, got {lo:.4f}"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_self_bleu_bounded():
|
| 51 |
+
for texts in (IDENTICAL, VARIED):
|
| 52 |
+
v = self_bleu(texts)
|
| 53 |
+
assert 0.0 <= v <= 1.0, v
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_self_bleu_handles_short_and_single():
|
| 57 |
+
assert self_bleu(["hi"]) == 0.0
|
| 58 |
+
assert self_bleu([]) == 0.0
|
| 59 |
+
assert 0.0 <= self_bleu(["a b", "c d"]) <= 1.0
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_cluster_count_two_clear_modes():
|
| 63 |
+
rng = np.random.default_rng(0)
|
| 64 |
+
a = rng.standard_normal(32); a /= np.linalg.norm(a)
|
| 65 |
+
b = rng.standard_normal(32); b /= np.linalg.norm(b)
|
| 66 |
+
E = np.vstack([np.tile(a, (8, 1)) + 0.02 * rng.standard_normal((8, 32)),
|
| 67 |
+
np.tile(b, (8, 1)) + 0.02 * rng.standard_normal((8, 32))])
|
| 68 |
+
E /= np.linalg.norm(E, axis=1, keepdims=True)
|
| 69 |
+
assert cluster_count(E) == 2, cluster_count(E)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_cluster_count_no_structure_is_one():
|
| 73 |
+
"""Near-identical embeddings have no cluster structure -> a single mode.
|
| 74 |
+
Originally FAILED at silhouette>0.05 (returned k=4 on a fully collapsed set),
|
| 75 |
+
which is why SILHOUETTE_MIN was recalibrated to 0.50."""
|
| 76 |
+
rng = np.random.default_rng(1)
|
| 77 |
+
a = rng.standard_normal(32); a /= np.linalg.norm(a)
|
| 78 |
+
E = np.tile(a, (16, 1)) + 0.001 * rng.standard_normal((16, 32))
|
| 79 |
+
E /= np.linalg.norm(E, axis=1, keepdims=True)
|
| 80 |
+
assert cluster_count(E) == 1, cluster_count(E)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_cluster_count_small_input():
|
| 84 |
+
assert cluster_count(np.eye(3)) == 1
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_topk_entropy_peaked_vs_flat():
|
| 88 |
+
"""A near-deterministic distribution has ~0 entropy; uniform over k has log k."""
|
| 89 |
+
peaked = [{"a": np.log(0.999), "b": np.log(0.001)}]
|
| 90 |
+
flat = [{c: np.log(0.25) for c in "abcd"}]
|
| 91 |
+
assert topk_entropy(peaked) < 0.05
|
| 92 |
+
assert abs(topk_entropy(flat) - np.log(4)) < 1e-6
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_topk_entropy_empty():
|
| 96 |
+
assert topk_entropy([]) == 0.0
|
| 97 |
+
assert topk_entropy([{}]) == 0.0
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_topk_entropy_renormalizes_truncated_table():
|
| 101 |
+
"""vLLM returns only the top-k, which does not sum to 1; we renormalize."""
|
| 102 |
+
partial = [{"a": np.log(0.4), "b": np.log(0.2)}] # sums to 0.6
|
| 103 |
+
h = topk_entropy(partial)
|
| 104 |
+
p = np.array([2 / 3, 1 / 3])
|
| 105 |
+
assert abs(h - float(-(p * np.log(p)).sum())) < 1e-9
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_eff_rank_identical_is_one():
|
| 109 |
+
rng = np.random.default_rng(3)
|
| 110 |
+
a = rng.standard_normal(32); a /= np.linalg.norm(a)
|
| 111 |
+
assert abs(effective_rank(np.tile(a, (16, 1))) - 1.0) < 1e-6
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_eff_rank_orthogonal_is_n():
|
| 115 |
+
Q, _ = np.linalg.qr(np.random.default_rng(4).standard_normal((32, 8)))
|
| 116 |
+
assert abs(effective_rank(Q.T[:8]) - 8.0) < 1e-6
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_eff_rank_two_clusters_is_about_two():
|
| 120 |
+
"""The case silhouette got right but only at a threshold that broke the
|
| 121 |
+
collapsed case. Effective rank handles both without a threshold."""
|
| 122 |
+
rng = np.random.default_rng(5)
|
| 123 |
+
a = rng.standard_normal(32); a /= np.linalg.norm(a)
|
| 124 |
+
b = rng.standard_normal(32); b /= np.linalg.norm(b)
|
| 125 |
+
E = np.vstack([np.tile(a, (8, 1)) + 0.02 * rng.standard_normal((8, 32)),
|
| 126 |
+
np.tile(b, (8, 1)) + 0.02 * rng.standard_normal((8, 32))])
|
| 127 |
+
E /= np.linalg.norm(E, axis=1, keepdims=True)
|
| 128 |
+
assert 1.8 < effective_rank(E) < 2.6, effective_rank(E)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_eff_rank_is_monotone_in_spread():
|
| 132 |
+
"""Monotone IN EXPECTATION. Averaged over draws because effective rank
|
| 133 |
+
saturates around 12.6 (not 16) for 16 unit vectors in 32 dims -- random
|
| 134 |
+
directions retain residual correlation -- so single draws at the top of the
|
| 135 |
+
range can invert by chance. The metric is fine; the assertion has to be
|
| 136 |
+
statistical."""
|
| 137 |
+
rng = np.random.default_rng(6)
|
| 138 |
+
a = rng.standard_normal(32); a /= np.linalg.norm(a)
|
| 139 |
+
prev = 0.0
|
| 140 |
+
for noise in (0.001, 0.05, 0.15, 0.5, 2.0):
|
| 141 |
+
vals = []
|
| 142 |
+
for _ in range(5):
|
| 143 |
+
E = np.tile(a, (16, 1)) + noise * rng.standard_normal((16, 32))
|
| 144 |
+
E /= np.linalg.norm(E, axis=1, keepdims=True)
|
| 145 |
+
vals.append(effective_rank(E))
|
| 146 |
+
r = float(np.mean(vals))
|
| 147 |
+
assert r >= prev - 1e-6, f"non-monotone at noise={noise}: {r} < {prev}"
|
| 148 |
+
prev = r
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_eff_rank_separates_collapsed_from_spread():
|
| 152 |
+
"""The exact discrimination silhouette FAILED: collapsed 0.202 vs spread
|
| 153 |
+
0.195 were indistinguishable. Effective rank must separate them clearly."""
|
| 154 |
+
rng = np.random.default_rng(7)
|
| 155 |
+
a = rng.standard_normal(32); a /= np.linalg.norm(a)
|
| 156 |
+
collapsed = np.tile(a, (16, 1)) + 0.001 * rng.standard_normal((16, 32))
|
| 157 |
+
collapsed /= np.linalg.norm(collapsed, axis=1, keepdims=True)
|
| 158 |
+
spread = rng.standard_normal((16, 32))
|
| 159 |
+
spread /= np.linalg.norm(spread, axis=1, keepdims=True)
|
| 160 |
+
assert effective_rank(spread) > effective_rank(collapsed) + 8.0
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
if __name__ == "__main__":
|
| 164 |
+
import sys, traceback
|
| 165 |
+
fns = [(n, f) for n, f in sorted(globals().items())
|
| 166 |
+
if n.startswith("test_") and callable(f)]
|
| 167 |
+
bad = 0
|
| 168 |
+
for n, f in fns:
|
| 169 |
+
try:
|
| 170 |
+
f(); print(f" PASS {n}")
|
| 171 |
+
except Exception:
|
| 172 |
+
bad += 1; print(f" FAIL {n}"); traceback.print_exc()
|
| 173 |
+
print(f"\n{len(fns)-bad}/{len(fns)} passed")
|
| 174 |
+
sys.exit(1 if bad else 0)
|
src/test_pairs.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Synthetic tests for E3/E4 preference-pair construction. Catches logic errors now
|
| 3 |
+
rather than four hours into the pipeline.
|
| 4 |
+
|
| 5 |
+
Most important: test_divpo_prob_picks_least_probable_as_chosen. DivPO's
|
| 6 |
+
probability variant inverts the usual intuition -- the CHOSEN story is the one
|
| 7 |
+
the model found LEAST likely. Getting that backwards would train the policy
|
| 8 |
+
straight into the greedy mode while the logs looked entirely normal.
|
| 9 |
+
"""
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
from build_pairs import build_divpo, build_multipos, normalize_weights
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def make_item(pid, idx, quality, dev, meanlp, passed=True, text=None):
|
| 16 |
+
return {"prompt_id": pid, "idx": idx, "prompt": f"prompt-{pid}",
|
| 17 |
+
"text": text or f"story-{pid}-{idx}", "quality": quality,
|
| 18 |
+
"deviation": dev, "mean_logprob": meanlp, "gate_passed": passed,
|
| 19 |
+
"marginal": -1.0, "n_words": 300}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def simple_pool():
|
| 23 |
+
"""One prompt, 6 stories spanning quality/deviation/logprob."""
|
| 24 |
+
return {"p0": [
|
| 25 |
+
make_item("p0", 0, 8.0, 0.40, -1.50), # high q, MOST diverse, least probable
|
| 26 |
+
make_item("p0", 1, 7.0, 0.20, -0.80), # high q, low dev -> "competent cliche"
|
| 27 |
+
make_item("p0", 2, 6.5, 0.30, -1.10),
|
| 28 |
+
make_item("p0", 3, 3.0, 0.35, -0.60), # low q, HIGH dev -> must NOT be chosen
|
| 29 |
+
make_item("p0", 4, 2.0, 0.05, -0.40), # low q, least diverse, MOST probable
|
| 30 |
+
make_item("p0", 5, 1.0, 0.10, -0.90, passed=False),
|
| 31 |
+
]}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# --------------------------------------------------------------------- E4
|
| 35 |
+
def test_divpo_emb_chosen_is_most_diverse_above_rho():
|
| 36 |
+
rows, st = build_divpo(simple_pool(), "emb", rho=6.0)
|
| 37 |
+
assert st["n_rows"] == 1
|
| 38 |
+
r = rows[0]
|
| 39 |
+
assert r["chosen"] == "story-p0-0", r
|
| 40 |
+
assert r["chosen_quality"] >= 6.0
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_divpo_emb_never_chooses_a_diverse_but_low_quality_story():
|
| 44 |
+
"""Story 3 has high deviation but quality 3.0. Quality gating must exclude
|
| 45 |
+
it -- this is the 'different because it is worse' failure."""
|
| 46 |
+
rows, _ = build_divpo(simple_pool(), "emb", rho=6.0)
|
| 47 |
+
assert rows[0]["chosen"] != "story-p0-3"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_divpo_emb_rejected_is_least_diverse_below_rho():
|
| 51 |
+
rows, _ = build_divpo(simple_pool(), "emb", rho=6.0)
|
| 52 |
+
assert rows[0]["rejected"] == "story-p0-4", rows[0]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_divpo_prob_picks_least_probable_as_chosen():
|
| 56 |
+
"""Most diverse == LOWEST length-normalized logprob; least diverse ==
|
| 57 |
+
HIGHEST (the near-greedy sample). Inverting this trains toward the mode."""
|
| 58 |
+
rows, _ = build_divpo(simple_pool(), "prob", rho=6.0)
|
| 59 |
+
r = rows[0]
|
| 60 |
+
assert r["chosen"] == "story-p0-0", f"chosen should be least probable: {r}"
|
| 61 |
+
assert r["rejected"] == "story-p0-4", f"rejected should be most probable: {r}"
|
| 62 |
+
assert r["chosen_meanlp"] < r["rejected_meanlp"]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_divpo_skips_prompt_with_no_qualifying_chosen():
|
| 66 |
+
pool = {"p0": [make_item("p0", i, 3.0, 0.2, -1.0) for i in range(4)]}
|
| 67 |
+
rows, st = build_divpo(pool, "emb", rho=6.0)
|
| 68 |
+
assert rows == [] and st["no_chosen"] == 1 and st["skip_rate"] == 1.0
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_divpo_skips_prompt_with_no_rejected():
|
| 72 |
+
pool = {"p0": [make_item("p0", i, 9.0, 0.2 + 0.01 * i, -1.0) for i in range(4)]}
|
| 73 |
+
rows, st = build_divpo(pool, "emb", rho=6.0)
|
| 74 |
+
assert rows == [] and st["no_rejected"] == 1
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_divpo_gate_failures_are_eligible_rejects():
|
| 78 |
+
"""A gate-failing story is legitimately in the low-quality tail."""
|
| 79 |
+
pool = {"p0": [make_item("p0", 0, 8.0, 0.4, -1.5),
|
| 80 |
+
make_item("p0", 1, 9.0, 0.3, -1.2),
|
| 81 |
+
make_item("p0", 2, 0.0, 0.01, -0.3, passed=False)]}
|
| 82 |
+
rows, st = build_divpo(pool, "emb", rho=6.0)
|
| 83 |
+
assert st["n_rows"] == 1 and rows[0]["rejected"] == "story-p0-2"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_divpo_is_one_row_per_prompt():
|
| 87 |
+
pool = {f"p{i}": simple_pool()["p0"] for i in range(5)}
|
| 88 |
+
rows, st = build_divpo(pool, "emb", rho=6.0)
|
| 89 |
+
assert len(rows) == 5 == st["n_rows"]
|
| 90 |
+
assert all(r["weight"] == 1.0 for r in rows), "DivPO must be unweighted"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# --------------------------------------------------------------------- E3
|
| 94 |
+
def _emb_for(pool):
|
| 95 |
+
"""Deterministic embeddings + row index aligned to pool iteration order."""
|
| 96 |
+
idx, vecs, i = {}, [], 0
|
| 97 |
+
rng = np.random.default_rng(0)
|
| 98 |
+
for pid, items in pool.items():
|
| 99 |
+
for it in items:
|
| 100 |
+
idx[(pid, it["idx"])] = i
|
| 101 |
+
v = rng.standard_normal(32)
|
| 102 |
+
vecs.append(v / np.linalg.norm(v))
|
| 103 |
+
i += 1
|
| 104 |
+
return np.array(vecs, dtype=np.float32), idx
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_multipos_emits_multiple_chosen_rows_per_prompt():
|
| 108 |
+
pool = simple_pool()
|
| 109 |
+
emb, idx = _emb_for(pool)
|
| 110 |
+
rows, st = build_multipos(pool, emb, idx, q_keep=5.0, k=4, lam=1.0)
|
| 111 |
+
assert st["ok"] == 1
|
| 112 |
+
assert 2 <= len(rows) <= 4, f"expected up to 4 chosen rows, got {len(rows)}"
|
| 113 |
+
assert all(r["chosen_quality"] >= 5.0 for r in rows)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_multipos_rotates_negatives():
|
| 117 |
+
"""Don't hammer one rejected across all rows."""
|
| 118 |
+
pool = simple_pool()
|
| 119 |
+
emb, idx = _emb_for(pool)
|
| 120 |
+
rows, _ = build_multipos(pool, emb, idx, q_keep=5.0, k=4, lam=1.0)
|
| 121 |
+
if len(rows) >= 2:
|
| 122 |
+
assert len(set(r["neg_type"] for r in rows)) == 2, \
|
| 123 |
+
f"negatives not rotated: {[r['neg_type'] for r in rows]}"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_multipos_weight_is_chosen_deviation():
|
| 127 |
+
pool = simple_pool()
|
| 128 |
+
emb, idx = _emb_for(pool)
|
| 129 |
+
rows, _ = build_multipos(pool, emb, idx, q_keep=5.0, k=4, lam=1.0)
|
| 130 |
+
for r in rows:
|
| 131 |
+
assert abs(r["weight"] - r["chosen_dev"]) < 1e-9
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_multipos_skips_prompt_with_too_few_survivors():
|
| 135 |
+
pool = {"p0": [make_item("p0", i, 2.0, 0.2, -1.0) for i in range(6)]}
|
| 136 |
+
emb, idx = _emb_for(pool)
|
| 137 |
+
rows, st = build_multipos(pool, emb, idx, q_keep=5.0, k=4, lam=1.0)
|
| 138 |
+
assert rows == [] and st["skipped_no_survivors"] == 1
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_normalize_weights_gives_mean_one():
|
| 142 |
+
rows = [{"weight": w} for w in (0.1, 0.2, 0.3, 0.8)]
|
| 143 |
+
normalize_weights(rows)
|
| 144 |
+
w = np.array([r["weight"] for r in rows])
|
| 145 |
+
assert abs(w.mean() - 1.0) < 1e-9
|
| 146 |
+
# relative ordering preserved -> it reweights, it does not rescale the LR
|
| 147 |
+
assert np.all(np.diff(w) > 0)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def test_normalize_weights_handles_all_zero():
|
| 151 |
+
rows = [{"weight": 0.0} for _ in range(3)]
|
| 152 |
+
normalize_weights(rows)
|
| 153 |
+
assert all(r["weight"] == 0.0 for r in rows)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
if __name__ == "__main__":
|
| 157 |
+
import sys, traceback
|
| 158 |
+
fns = [(n, f) for n, f in sorted(globals().items())
|
| 159 |
+
if n.startswith("test_") and callable(f)]
|
| 160 |
+
bad = 0
|
| 161 |
+
for n, f in fns:
|
| 162 |
+
try:
|
| 163 |
+
f(); print(f" PASS {n}")
|
| 164 |
+
except Exception:
|
| 165 |
+
bad += 1; print(f" FAIL {n}"); traceback.print_exc()
|
| 166 |
+
print(f"\n{len(fns)-bad}/{len(fns)} passed")
|
| 167 |
+
sys.exit(1 if bad else 0)
|
src/test_rewards.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the reward-channel construction in rewards.py, with a fake judge so
|
| 3 |
+
nothing hits the network.
|
| 4 |
+
|
| 5 |
+
The load-bearing test is test_gated_sample_never_outranks_in_marginal_channel:
|
| 6 |
+
m_i <= log(1+eps) ~ 0 is ALWAYS negative, so gating a failed story to 0.0 would
|
| 7 |
+
hand it the highest diversity credit in the group. That would be a reward-hacking
|
| 8 |
+
channel we built ourselves, and it is exactly the kind of sign error that is
|
| 9 |
+
invisible in aggregate training curves.
|
| 10 |
+
"""
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
import gates as G
|
| 14 |
+
import rewards as R
|
| 15 |
+
from judge import JudgeScore
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class FakeJudge:
|
| 19 |
+
"""Scores by a marker embedded in the text; no network."""
|
| 20 |
+
def __init__(self, mapping=None):
|
| 21 |
+
self.mapping = mapping or {}
|
| 22 |
+
self.calls = 0
|
| 23 |
+
|
| 24 |
+
def score_many_sync(self, pairs):
|
| 25 |
+
self.calls += len(pairs)
|
| 26 |
+
out = []
|
| 27 |
+
for _, story in pairs:
|
| 28 |
+
q = 8.0
|
| 29 |
+
for k, v in self.mapping.items():
|
| 30 |
+
if k in story:
|
| 31 |
+
q = v
|
| 32 |
+
break
|
| 33 |
+
out.append(JudgeScore(quality=q, novelty=5.0))
|
| 34 |
+
return out
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _story(marker: str, n_words: int = 250, seed: int = 0) -> str:
|
| 38 |
+
"""Gate-passing filler with a marker and enough lexical variety to clear
|
| 39 |
+
the 4-gram-loop and entropy gates."""
|
| 40 |
+
rng = np.random.default_rng(seed)
|
| 41 |
+
vocab = ["harbor", "clock", "ember", "listen", "gravel", "orchard", "signal",
|
| 42 |
+
"letter", "winter", "throat", "marble", "engine", "sister", "quiet",
|
| 43 |
+
"amber", "hollow", "ribbon", "tunnel", "pepper", "静"][:19]
|
| 44 |
+
words = [vocab[i] for i in rng.integers(0, len(vocab), n_words)]
|
| 45 |
+
return f"{marker} " + " ".join(words) + "."
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _engine(arm="E2", tau=5.0, judge=None):
|
| 49 |
+
cfg = R.RewardConfig(arm=arm, tau=tau)
|
| 50 |
+
return R.RewardEngine(cfg, judge or FakeJudge())
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _fake_embed(monkey_vals):
|
| 54 |
+
"""Patch rewards.embed to return a fixed matrix."""
|
| 55 |
+
R.embed = lambda texts: monkey_vals
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ------------------------------------------------------------------ configs
|
| 59 |
+
def test_channels_and_weights_per_arm():
|
| 60 |
+
assert R.RewardConfig(arm="E0").channels() == ["quality"]
|
| 61 |
+
assert R.RewardConfig(arm="E1").channels() == ["quality", "deviation"]
|
| 62 |
+
assert R.RewardConfig(arm="E2").channels() == ["quality", "deviation", "marginal"]
|
| 63 |
+
assert R.RewardConfig(arm="E2", alpha=0.3, gamma=0.7).weights() == [1.0, 0.3, 0.7]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ------------------------------------------------------------------- gating
|
| 67 |
+
def test_gate_failure_zeroes_quality_channel():
|
| 68 |
+
eng = _engine("E1")
|
| 69 |
+
texts = [_story("GOOD", 250, i) for i in range(3)] + ["too short."]
|
| 70 |
+
prompts = ["p"] * 4
|
| 71 |
+
out = eng.compute(prompts, texts)
|
| 72 |
+
assert out["quality"][3] == 0.0, "gate-failed story must floor the quality channel"
|
| 73 |
+
assert (out["quality"][:3] > 0).all()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_judge_is_not_called_for_gate_failures():
|
| 77 |
+
"""We must never pay to score text we have already decided to zero."""
|
| 78 |
+
j = FakeJudge()
|
| 79 |
+
eng = _engine("E1", judge=j)
|
| 80 |
+
texts = [_story("A", 250, 1), "nope.", "also short."]
|
| 81 |
+
eng.compute(["p"] * 3, texts)
|
| 82 |
+
assert j.calls == 1, f"judge called {j.calls} times, expected 1"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_low_quality_forfeits_diversity_credit():
|
| 86 |
+
"""tau conditioning: a coherent but low-quality story earns no diversity."""
|
| 87 |
+
j = FakeJudge({"BAD": 2.0, "GOOD": 8.0})
|
| 88 |
+
eng = _engine("E1", tau=5.0, judge=j)
|
| 89 |
+
texts = [_story("GOOD", 250, i) for i in range(3)] + [_story("BAD", 250, 9)]
|
| 90 |
+
out = eng.compute(["p"] * 4, texts)
|
| 91 |
+
dev = out["deviation"]
|
| 92 |
+
assert dev[3] <= dev[:3].min() + 1e-12, \
|
| 93 |
+
f"sub-tau story got deviation credit {dev[3]} vs eligible min {dev[:3].min()}"
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_gated_sample_never_outranks_in_marginal_channel():
|
| 97 |
+
"""THE sign trap. m_i is always <= 0, so gating to 0.0 would make failure
|
| 98 |
+
the single best value in the channel."""
|
| 99 |
+
j = FakeJudge({"BAD": 1.0, "GOOD": 8.0})
|
| 100 |
+
eng = _engine("E2", tau=5.0, judge=j)
|
| 101 |
+
texts = [_story("GOOD", 250, i) for i in range(5)] + [_story("BAD", 250, 42)]
|
| 102 |
+
out = eng.compute(["p"] * 6, texts)
|
| 103 |
+
m = out["marginal"]
|
| 104 |
+
assert m[5] <= m[:5].min() + 1e-12, \
|
| 105 |
+
f"ineligible story ranked ABOVE eligible ones in marginal channel: {m}"
|
| 106 |
+
assert m[5] != 0.0 or np.allclose(m, 0.0), "suspicious exact-zero gate value"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_all_gated_group_is_constant_not_nan():
|
| 110 |
+
"""Every sample failing => channels go constant. TRL's (x-mean)/(std+1e-4)
|
| 111 |
+
then yields ~0 for all, which is correct (no signal), and must not be NaN."""
|
| 112 |
+
eng = _engine("E2")
|
| 113 |
+
texts = ["short."] * 4
|
| 114 |
+
out = eng.compute(["p"] * 4, texts)
|
| 115 |
+
for ch, v in out.items():
|
| 116 |
+
assert np.all(np.isfinite(v)), f"{ch} produced non-finite values: {v}"
|
| 117 |
+
assert np.allclose(v, v[0]), f"{ch} should be constant when all gated"
|
| 118 |
+
assert eng.last_stats.frac_groups_degenerate == 1.0
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_duplicates_get_low_marginal_within_group():
|
| 122 |
+
"""Two identical stories should each be worth little in the log-det channel."""
|
| 123 |
+
j = FakeJudge()
|
| 124 |
+
eng = _engine("E2", judge=j)
|
| 125 |
+
dup = _story("DUP", 250, 7)
|
| 126 |
+
texts = [dup, dup] + [_story("X", 250, i) for i in range(3, 7)]
|
| 127 |
+
out = eng.compute(["p"] * 6, texts)
|
| 128 |
+
m = out["marginal"]
|
| 129 |
+
assert m[0] < m[2:].mean() and m[1] < m[2:].mean(), \
|
| 130 |
+
f"duplicates were not penalized in the marginal channel: {m}"
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_two_groups_are_scored_independently():
|
| 134 |
+
j = FakeJudge()
|
| 135 |
+
eng = _engine("E1", judge=j)
|
| 136 |
+
texts = [_story("A", 250, i) for i in range(4)] + [_story("B", 250, i + 10) for i in range(4)]
|
| 137 |
+
prompts = ["p1"] * 4 + ["p2"] * 4
|
| 138 |
+
out = eng.compute(prompts, texts)
|
| 139 |
+
assert len(out["deviation"]) == 8
|
| 140 |
+
assert eng.last_stats.n == 8
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_reward_funcs_match_channel_order():
|
| 144 |
+
eng = _engine("E2")
|
| 145 |
+
fns = eng.make_reward_funcs()
|
| 146 |
+
assert [f.__name__ for f in fns] == \
|
| 147 |
+
["quality_reward", "deviation_reward", "marginal_reward"]
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def test_engine_memoizes_within_a_batch():
|
| 151 |
+
"""TRL calls one reward fn per channel; the judge+embedder must run once."""
|
| 152 |
+
j = FakeJudge()
|
| 153 |
+
eng = _engine("E2", judge=j)
|
| 154 |
+
texts = [_story("A", 250, i) for i in range(4)]
|
| 155 |
+
comps = [[{"role": "assistant", "content": t}] for t in texts]
|
| 156 |
+
prompts = [[{"role": "user", "content": "p"}]] * 4
|
| 157 |
+
fns = eng.make_reward_funcs()
|
| 158 |
+
for f in fns:
|
| 159 |
+
f(comps, prompts=prompts)
|
| 160 |
+
assert j.calls == 4, f"judge called {j.calls} times; memoization failed"
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
if __name__ == "__main__":
|
| 164 |
+
import sys, traceback
|
| 165 |
+
fns = [(n, f) for n, f in sorted(globals().items())
|
| 166 |
+
if n.startswith("test_") and callable(f)]
|
| 167 |
+
bad = 0
|
| 168 |
+
for n, f in fns:
|
| 169 |
+
try:
|
| 170 |
+
f(); print(f" PASS {n}")
|
| 171 |
+
except Exception:
|
| 172 |
+
bad += 1; print(f" FAIL {n}"); traceback.print_exc()
|
| 173 |
+
print(f"\n{len(fns)-bad}/{len(fns)} passed")
|
| 174 |
+
sys.exit(1 if bad else 0)
|
src/train_dpo.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Offline preference training for E3 (multi-positive, deviation-weighted) and
|
| 3 |
+
E4 (faithful DivPO, unweighted). Same LoRA config, same beta, same data pool --
|
| 4 |
+
only pair construction and loss weighting differ.
|
| 5 |
+
|
| 6 |
+
DDPO-STYLE PER-SAMPLE LOSS WEIGHTING (E3)
|
| 7 |
+
-----------------------------------------
|
| 8 |
+
TRL's DPOConfig.loss_weights is per LOSS TYPE (for blending sigmoid+hinge), not
|
| 9 |
+
per example, so it cannot express "weight this pair by its chosen story's
|
| 10 |
+
deviation". Reimplementing TRL's _compute_loss to reach `per_sequence_loss`
|
| 11 |
+
would mean maintaining a fork of a 300-line method across every loss variant.
|
| 12 |
+
|
| 13 |
+
Instead we exploit an identity: with per_device_train_batch_size == 1, the
|
| 14 |
+
batch loss IS that single example's loss, so
|
| 15 |
+
|
| 16 |
+
loss_i * w_i accumulated over gradient_accumulation_steps
|
| 17 |
+
|
| 18 |
+
is exactly the weighted-DPO gradient, with no TRL surgery at all. Cost is
|
| 19 |
+
running at batch size 1, which for ~4k short rows on a 4B LoRA is minutes.
|
| 20 |
+
|
| 21 |
+
The weight rides through the collator (TRL drops unknown columns otherwise) and
|
| 22 |
+
is popped before the model call. Weights are pre-normalized to mean 1.0 in
|
| 23 |
+
build_pairs.py so the weighting changes RELATIVE emphasis across rows without
|
| 24 |
+
also rescaling the effective learning rate -- otherwise "DDPO weighting" and
|
| 25 |
+
"lower LR" would be confounded.
|
| 26 |
+
"""
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import json
|
| 31 |
+
import os
|
| 32 |
+
import sys
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
import torch
|
| 36 |
+
import yaml
|
| 37 |
+
|
| 38 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def load_rows(path: Path) -> list[dict]:
|
| 42 |
+
return [json.loads(l) for l in open(path) if l.strip()]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def build_dpo_dataset(rows: list[dict], weighted: bool):
|
| 46 |
+
from datasets import Dataset
|
| 47 |
+
from data import SYSTEM_PROMPT
|
| 48 |
+
|
| 49 |
+
recs = []
|
| 50 |
+
for r in rows:
|
| 51 |
+
recs.append({
|
| 52 |
+
"prompt": [{"role": "system", "content": SYSTEM_PROMPT},
|
| 53 |
+
{"role": "user", "content": r["prompt"]}],
|
| 54 |
+
"chosen": [{"role": "assistant", "content": r["chosen"]}],
|
| 55 |
+
"rejected": [{"role": "assistant", "content": r["rejected"]}],
|
| 56 |
+
"weight": float(r.get("weight", 1.0)) if weighted else 1.0,
|
| 57 |
+
})
|
| 58 |
+
return Dataset.from_list(recs)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def make_weighted_classes():
|
| 62 |
+
from trl import DPOTrainer
|
| 63 |
+
from trl.trainer.dpo_trainer import DataCollatorForPreference
|
| 64 |
+
|
| 65 |
+
class WeightedCollator(DataCollatorForPreference):
|
| 66 |
+
def __call__(self, features, return_tensors=None):
|
| 67 |
+
w = [float(f.pop("weight", 1.0)) for f in features]
|
| 68 |
+
batch = super().__call__(features, return_tensors)
|
| 69 |
+
batch["weight"] = torch.tensor(w, dtype=torch.float32)
|
| 70 |
+
return batch
|
| 71 |
+
|
| 72 |
+
class WeightedDPOTrainer(DPOTrainer):
|
| 73 |
+
"""Exact per-sample weighting, valid because batch size is 1."""
|
| 74 |
+
|
| 75 |
+
def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None):
|
| 76 |
+
w = inputs.pop("weight", None)
|
| 77 |
+
out = super().compute_loss(model, inputs, return_outputs=return_outputs,
|
| 78 |
+
num_items_in_batch=num_items_in_batch)
|
| 79 |
+
if w is None:
|
| 80 |
+
return out
|
| 81 |
+
scale = w.to(out[0].device if return_outputs else out.device).mean()
|
| 82 |
+
if return_outputs:
|
| 83 |
+
loss, extra = out
|
| 84 |
+
return loss * scale, extra
|
| 85 |
+
return out * scale
|
| 86 |
+
|
| 87 |
+
return WeightedCollator, WeightedDPOTrainer
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def main():
|
| 91 |
+
ap = argparse.ArgumentParser()
|
| 92 |
+
ap.add_argument("--config", required=True)
|
| 93 |
+
ap.add_argument("--max-steps", type=int, default=None)
|
| 94 |
+
ap.add_argument("--smoke", action="store_true")
|
| 95 |
+
args = ap.parse_args()
|
| 96 |
+
|
| 97 |
+
cfg = yaml.safe_load(open(args.config))
|
| 98 |
+
name = cfg["name"] + ("-smoke" if args.smoke else "")
|
| 99 |
+
|
| 100 |
+
import wandb
|
| 101 |
+
from peft import LoraConfig
|
| 102 |
+
from transformers import AutoTokenizer
|
| 103 |
+
from trl import DPOConfig, DPOTrainer
|
| 104 |
+
from trl.trainer.dpo_trainer import DataCollatorForPreference
|
| 105 |
+
|
| 106 |
+
import logbook
|
| 107 |
+
|
| 108 |
+
weighted = bool(cfg["dpo"].get("weighted", False))
|
| 109 |
+
pairs_path = ROOT / cfg["dpo"]["pairs"]
|
| 110 |
+
rows = load_rows(pairs_path)
|
| 111 |
+
if args.smoke:
|
| 112 |
+
rows = rows[:64]
|
| 113 |
+
print(f"[{name}] {len(rows)} preference rows | weighted={weighted}")
|
| 114 |
+
|
| 115 |
+
run = None
|
| 116 |
+
if cfg.get("wandb", True) and os.environ.get("WANDB_API_KEY"):
|
| 117 |
+
run = wandb.init(project=os.environ.get("WANDB_PROJECT", "div-grpo"),
|
| 118 |
+
name=name, config=cfg, reinit=True)
|
| 119 |
+
|
| 120 |
+
tok = AutoTokenizer.from_pretrained(cfg["model"])
|
| 121 |
+
ds = build_dpo_dataset(rows, weighted)
|
| 122 |
+
|
| 123 |
+
lora = LoraConfig(
|
| 124 |
+
r=cfg["lora"]["r"], lora_alpha=cfg["lora"]["alpha"],
|
| 125 |
+
lora_dropout=cfg["lora"].get("dropout", 0.0),
|
| 126 |
+
target_modules=cfg["lora"]["target_modules"],
|
| 127 |
+
task_type="CAUSAL_LM", bias="none",
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
out_dir = ROOT / "outputs" / name
|
| 131 |
+
bs = 1 if weighted else cfg["dpo"].get("per_device_train_batch_size", 2)
|
| 132 |
+
dcfg = DPOConfig(
|
| 133 |
+
output_dir=str(out_dir),
|
| 134 |
+
num_train_epochs=cfg["dpo"].get("epochs", 1),
|
| 135 |
+
max_steps=args.max_steps or -1,
|
| 136 |
+
per_device_train_batch_size=bs,
|
| 137 |
+
gradient_accumulation_steps=cfg["dpo"].get("gradient_accumulation_steps", 8),
|
| 138 |
+
learning_rate=cfg["dpo"]["learning_rate"],
|
| 139 |
+
beta=cfg["dpo"].get("beta", 0.1),
|
| 140 |
+
loss_type=cfg["dpo"].get("loss_type", "sigmoid"),
|
| 141 |
+
# TRL 1.10 dropped max_prompt_length; max_length bounds prompt+completion
|
| 142 |
+
# jointly. Sized so NOTHING truncates: prompt ~180 tok + a story capped
|
| 143 |
+
# by the 600-word gate (~780 tok) = ~960, well inside 1600.
|
| 144 |
+
max_length=cfg["dpo"].get("max_length", 1600),
|
| 145 |
+
truncation_mode=cfg["dpo"].get("truncation_mode", "keep_start"),
|
| 146 |
+
lr_scheduler_type=cfg["dpo"].get("lr_scheduler_type", "cosine"),
|
| 147 |
+
warmup_steps=cfg["dpo"].get("warmup_steps", 20), # TRL 1.10: no warmup_ratio
|
| 148 |
+
bf16=True,
|
| 149 |
+
gradient_checkpointing=True,
|
| 150 |
+
logging_steps=10,
|
| 151 |
+
save_strategy="no",
|
| 152 |
+
remove_unused_columns=not weighted, # keep `weight` alive when weighting
|
| 153 |
+
report_to=["wandb"] if run else [],
|
| 154 |
+
run_name=name,
|
| 155 |
+
seed=cfg.get("seed", 42),
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
if weighted:
|
| 159 |
+
WCollator, WTrainer = make_weighted_classes()
|
| 160 |
+
collator = WCollator(pad_token_id=tok.pad_token_id or tok.eos_token_id)
|
| 161 |
+
trainer = WTrainer(model=cfg["model"], args=dcfg, train_dataset=ds,
|
| 162 |
+
processing_class=tok, peft_config=lora,
|
| 163 |
+
data_collator=collator)
|
| 164 |
+
else:
|
| 165 |
+
trainer = DPOTrainer(model=cfg["model"], args=dcfg, train_dataset=ds,
|
| 166 |
+
processing_class=tok, peft_config=lora)
|
| 167 |
+
|
| 168 |
+
logbook.note(f"START {name}",
|
| 169 |
+
f"rows={len(rows)} weighted={weighted} bs={bs} "
|
| 170 |
+
f"beta={dcfg.beta} epochs={dcfg.num_train_epochs}\n\n"
|
| 171 |
+
f"```yaml\n{yaml.safe_dump(cfg, sort_keys=False)}```")
|
| 172 |
+
|
| 173 |
+
trainer.train()
|
| 174 |
+
final = out_dir / "final"
|
| 175 |
+
trainer.save_model(str(final))
|
| 176 |
+
tok.save_pretrained(str(final))
|
| 177 |
+
|
| 178 |
+
hist = trainer.state.log_history
|
| 179 |
+
json.dump(hist, open(out_dir / "log_history.json", "w"), indent=1)
|
| 180 |
+
last = [h for h in hist if "loss" in h]
|
| 181 |
+
logbook.note(f"DONE {name}",
|
| 182 |
+
f"adapter: `{final}`\n\nfinal loss: "
|
| 183 |
+
f"`{last[-1] if last else 'n/a'}`")
|
| 184 |
+
logbook.checkpoint(f"after {name}")
|
| 185 |
+
if run:
|
| 186 |
+
run.finish()
|
| 187 |
+
return 0
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
if __name__ == "__main__":
|
| 191 |
+
sys.exit(main())
|
src/train_grpo.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GRPO training for E0 (quality-only baseline), E1 (div-grpo-individual),
|
| 3 |
+
E2 (div-grpo-group). The three arms differ ONLY by YAML config -- same code
|
| 4 |
+
path, same data, same seed -- so any difference between them is attributable
|
| 5 |
+
to the reward configuration and nothing else.
|
| 6 |
+
|
| 7 |
+
Aggregation is GDPO (arXiv 2601.05242, Liu et al., NVIDIA): group-wise
|
| 8 |
+
normalization per reward channel, then batch-wise advantage normalization.
|
| 9 |
+
TRL 1.10 implements this as multi_objective_aggregation="normalize_then_sum".
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
from dataclasses import asdict
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import yaml
|
| 21 |
+
|
| 22 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def build_dataset(prompts, tokenizer):
|
| 26 |
+
from datasets import Dataset
|
| 27 |
+
from data import chat_messages
|
| 28 |
+
return Dataset.from_list([
|
| 29 |
+
{"prompt": chat_messages(p["prompt"]), "prompt_id": p["id"]}
|
| 30 |
+
for p in prompts
|
| 31 |
+
])
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def main():
|
| 35 |
+
ap = argparse.ArgumentParser()
|
| 36 |
+
ap.add_argument("--config", required=True)
|
| 37 |
+
ap.add_argument("--max-steps", type=int, default=None, help="override (smoke tests)")
|
| 38 |
+
ap.add_argument("--smoke", action="store_true")
|
| 39 |
+
args = ap.parse_args()
|
| 40 |
+
|
| 41 |
+
cfg = yaml.safe_load(open(args.config))
|
| 42 |
+
name = cfg["name"]
|
| 43 |
+
if args.smoke:
|
| 44 |
+
name = f"{name}-smoke"
|
| 45 |
+
|
| 46 |
+
import torch
|
| 47 |
+
import wandb
|
| 48 |
+
from peft import LoraConfig
|
| 49 |
+
from transformers import AutoTokenizer, TrainerCallback
|
| 50 |
+
from trl import GRPOConfig, GRPOTrainer
|
| 51 |
+
|
| 52 |
+
import logbook
|
| 53 |
+
from data import load_prompts
|
| 54 |
+
from judge import build_judge
|
| 55 |
+
from rewards import RewardConfig, RewardEngine
|
| 56 |
+
|
| 57 |
+
out_dir = ROOT / "outputs" / name
|
| 58 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
|
| 60 |
+
steps = args.max_steps or cfg["train"]["max_steps"]
|
| 61 |
+
model_id = cfg["model"]
|
| 62 |
+
G = cfg["train"]["num_generations"]
|
| 63 |
+
|
| 64 |
+
# ---- wandb -----------------------------------------------------------
|
| 65 |
+
run = None
|
| 66 |
+
if cfg.get("wandb", True) and os.environ.get("WANDB_API_KEY"):
|
| 67 |
+
run = wandb.init(
|
| 68 |
+
project=os.environ.get("WANDB_PROJECT", "div-grpo"),
|
| 69 |
+
name=name, config=cfg, reinit=True,
|
| 70 |
+
mode=os.environ.get("WANDB_MODE", "online"),
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
logbook.note(f"START {name}",
|
| 74 |
+
f"```yaml\n{yaml.safe_dump(cfg, sort_keys=False)}```\n"
|
| 75 |
+
f"steps={steps} G={G} model={model_id}")
|
| 76 |
+
|
| 77 |
+
# ---- reward engine ---------------------------------------------------
|
| 78 |
+
rcfg = RewardConfig(
|
| 79 |
+
arm=cfg["reward"]["arm"],
|
| 80 |
+
alpha=cfg["reward"].get("alpha", 0.5),
|
| 81 |
+
gamma=cfg["reward"].get("gamma", 0.5),
|
| 82 |
+
tau=cfg["reward"].get("tau", 5.0),
|
| 83 |
+
)
|
| 84 |
+
judge = build_judge(
|
| 85 |
+
model=cfg["judge"]["model"],
|
| 86 |
+
cache_path=str(ROOT / "cache" / "judge.sqlite"),
|
| 87 |
+
concurrency=cfg["judge"].get("concurrency", 12),
|
| 88 |
+
)
|
| 89 |
+
engine = RewardEngine(rcfg, judge, wandb_run=run, log_prefix="train")
|
| 90 |
+
reward_funcs = engine.make_reward_funcs()
|
| 91 |
+
weights = rcfg.weights()
|
| 92 |
+
print(f"[arm {rcfg.arm}] channels={rcfg.channels()} weights={weights} tau={rcfg.tau}")
|
| 93 |
+
|
| 94 |
+
# ---- data ------------------------------------------------------------
|
| 95 |
+
tokenizer = AutoTokenizer.from_pretrained(model_id)
|
| 96 |
+
train_prompts = load_prompts("train", ROOT / "data")
|
| 97 |
+
if args.smoke:
|
| 98 |
+
train_prompts = train_prompts[:64]
|
| 99 |
+
train_ds = build_dataset(train_prompts, tokenizer)
|
| 100 |
+
|
| 101 |
+
# ---- LoRA ------------------------------------------------------------
|
| 102 |
+
lora = LoraConfig(
|
| 103 |
+
r=cfg["lora"]["r"],
|
| 104 |
+
lora_alpha=cfg["lora"]["alpha"],
|
| 105 |
+
lora_dropout=cfg["lora"].get("dropout", 0.0),
|
| 106 |
+
target_modules=cfg["lora"]["target_modules"],
|
| 107 |
+
task_type="CAUSAL_LM",
|
| 108 |
+
bias="none",
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
gcfg = GRPOConfig(
|
| 112 |
+
output_dir=str(out_dir),
|
| 113 |
+
max_steps=steps,
|
| 114 |
+
per_device_train_batch_size=cfg["train"]["per_device_train_batch_size"],
|
| 115 |
+
gradient_accumulation_steps=cfg["train"]["gradient_accumulation_steps"],
|
| 116 |
+
num_generations=G,
|
| 117 |
+
max_completion_length=cfg["train"]["max_completion_length"],
|
| 118 |
+
# TRL 1.10 dropped max_prompt_length; vLLM's window is the control now.
|
| 119 |
+
vllm_max_model_length=cfg["train"].get("vllm_max_model_length", 2048),
|
| 120 |
+
# NOT masking truncated completions: a truncated story is gated to the
|
| 121 |
+
# bottom of every reward channel, and we want that negative gradient to
|
| 122 |
+
# reach the policy. Masking would make truncation free.
|
| 123 |
+
mask_truncated_completions=False,
|
| 124 |
+
learning_rate=cfg["train"]["learning_rate"],
|
| 125 |
+
lr_scheduler_type=cfg["train"].get("lr_scheduler_type", "constant_with_warmup"),
|
| 126 |
+
warmup_steps=cfg["train"].get("warmup_steps", 10),
|
| 127 |
+
beta=cfg["train"]["beta"],
|
| 128 |
+
temperature=cfg["train"].get("temperature", 1.0),
|
| 129 |
+
top_p=cfg["train"].get("top_p", 1.0),
|
| 130 |
+
# GDPO: per-reward group normalization, then batch-level advantage norm
|
| 131 |
+
multi_objective_aggregation="normalize_then_sum",
|
| 132 |
+
reward_weights=weights,
|
| 133 |
+
scale_rewards=cfg["train"].get("scale_rewards", "group"),
|
| 134 |
+
bf16=True,
|
| 135 |
+
gradient_checkpointing=True,
|
| 136 |
+
# Liger fuses RMSNorm/SwiGLU/RoPE and the LM-head cross-entropy, which
|
| 137 |
+
# is where the peak lives: the logits tensor is
|
| 138 |
+
# micro_batch x seq x 151936 vocab, and it was the allocation that OOMed.
|
| 139 |
+
use_liger_kernel=cfg["train"].get("use_liger_kernel", True),
|
| 140 |
+
torch_empty_cache_steps=cfg["train"].get("torch_empty_cache_steps", 8),
|
| 141 |
+
use_vllm=True,
|
| 142 |
+
vllm_mode="colocate",
|
| 143 |
+
vllm_gpu_memory_utilization=cfg["train"]["vllm_gpu_memory_utilization"],
|
| 144 |
+
logging_steps=1,
|
| 145 |
+
save_steps=cfg["train"].get("save_steps", 50),
|
| 146 |
+
save_total_limit=cfg["train"].get("save_total_limit", 7),
|
| 147 |
+
# Checkpoints exist only to EVALUATE intermediate policies (ckpt_study),
|
| 148 |
+
# never to resume training. Without this, HF writes a 505MB optimizer.pt
|
| 149 |
+
# beside a 253MB adapter -- 3x the disk for state we never read. This was
|
| 150 |
+
# set in the YAML from E1 onward but not passed through until now.
|
| 151 |
+
save_only_model=cfg["train"].get("save_only_model", True),
|
| 152 |
+
seed=cfg.get("seed", 42),
|
| 153 |
+
report_to=["wandb"] if run else [],
|
| 154 |
+
run_name=name,
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
trainer = GRPOTrainer(
|
| 158 |
+
model=model_id,
|
| 159 |
+
reward_funcs=reward_funcs,
|
| 160 |
+
args=gcfg,
|
| 161 |
+
train_dataset=train_ds,
|
| 162 |
+
peft_config=lora,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# ---- periodic reward-hacking guardrail -------------------------------
|
| 166 |
+
class Guardrail(TrainerCallback):
|
| 167 |
+
"""Stop the run if diversity climbs while quality/validity collapses.
|
| 168 |
+
|
| 169 |
+
The brief's guardrail: 'if reward hacking appears (deviation up, quality
|
| 170 |
+
flat/down, or degenerate text passing gates), stop the run'. We compare a
|
| 171 |
+
trailing window against the opening baseline rather than step-to-step,
|
| 172 |
+
because GRPO reward traces are far too noisy for a point comparison.
|
| 173 |
+
"""
|
| 174 |
+
WINDOW = 25
|
| 175 |
+
|
| 176 |
+
def on_step_end(self, a, state, control, **kw):
|
| 177 |
+
h = engine.history
|
| 178 |
+
if len(h) < self.WINDOW * 2:
|
| 179 |
+
return
|
| 180 |
+
base = h[:self.WINDOW]
|
| 181 |
+
recent = h[-self.WINDOW:]
|
| 182 |
+
|
| 183 |
+
def mean(rows, f):
|
| 184 |
+
return sum(f(r) for r in rows) / len(rows)
|
| 185 |
+
|
| 186 |
+
gate0, gate1 = mean(base, lambda r: r.gate_pass), mean(recent, lambda r: r.gate_pass)
|
| 187 |
+
q0, q1 = mean(base, lambda r: r.mean_quality_passing), mean(recent, lambda r: r.mean_quality_passing)
|
| 188 |
+
d0, d1 = mean(base, lambda r: r.mean_deviation), mean(recent, lambda r: r.mean_deviation)
|
| 189 |
+
|
| 190 |
+
msg = None
|
| 191 |
+
if gate1 < 0.55 and gate1 < gate0 - 0.25:
|
| 192 |
+
msg = f"gate pass collapsed {gate0:.2f}->{gate1:.2f}"
|
| 193 |
+
elif d1 > d0 + 0.05 and q1 < q0 - 1.0:
|
| 194 |
+
msg = f"reward hacking: deviation {d0:.3f}->{d1:.3f} while quality {q0:.2f}->{q1:.2f}"
|
| 195 |
+
if msg:
|
| 196 |
+
logbook.note(f"GUARDRAIL TRIP {name}", msg, level="ALERT")
|
| 197 |
+
print(f"\n!!! GUARDRAIL: {msg} -- stopping at step {state.global_step}\n", flush=True)
|
| 198 |
+
control.should_training_stop = True
|
| 199 |
+
|
| 200 |
+
trainer.add_callback(Guardrail())
|
| 201 |
+
|
| 202 |
+
print(f"\n── training {name}: {steps} steps ──", flush=True)
|
| 203 |
+
trainer.train()
|
| 204 |
+
|
| 205 |
+
final = out_dir / "final"
|
| 206 |
+
trainer.save_model(str(final))
|
| 207 |
+
tokenizer.save_pretrained(str(final))
|
| 208 |
+
|
| 209 |
+
hist = [asdict(s) for s in engine.history]
|
| 210 |
+
json.dump(hist, open(out_dir / "reward_history.json", "w"), indent=1)
|
| 211 |
+
# TRL's own log history carries per-token policy entropy, KL and clip ratio.
|
| 212 |
+
# Entropy is only present on the non-liger loss path (compute_liger_loss logs
|
| 213 |
+
# just clip_ratio and kl), which is why use_liger_kernel is disabled.
|
| 214 |
+
json.dump(trainer.state.log_history,
|
| 215 |
+
open(out_dir / "trl_log_history.json", "w"), indent=1)
|
| 216 |
+
ent = [h["entropy"] for h in trainer.state.log_history if "entropy" in h]
|
| 217 |
+
print(f"entropy logged for {len(ent)} steps"
|
| 218 |
+
+ (f" | first={ent[0]:.4f} last={ent[-1]:.4f}" if ent else " -- MISSING!"))
|
| 219 |
+
cost = judge.cost_estimate(cfg["judge"]["price_in"], cfg["judge"]["price_out"])
|
| 220 |
+
json.dump(cost, open(out_dir / "judge_cost.json", "w"), indent=1)
|
| 221 |
+
print("judge cost:", cost)
|
| 222 |
+
|
| 223 |
+
logbook.note(f"DONE {name}",
|
| 224 |
+
f"adapter: `{final}`\n\njudge cost: `{json.dumps(cost)}`")
|
| 225 |
+
logbook.checkpoint(f"after {name}")
|
| 226 |
+
if run:
|
| 227 |
+
run.finish()
|
| 228 |
+
return 0
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
if __name__ == "__main__":
|
| 232 |
+
sys.exit(main())
|