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 PDF report generator
Browse files- src/make_pdf.py +376 -0
src/make_pdf.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build the final PDF report: metrics, figures, and example stories."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import base64, csv, json, re, sys
|
| 4 |
+
from collections import Counter
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 10 |
+
FIGS = ROOT / "logs" / "figures"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def img(p: Path, w="100%") -> str:
|
| 14 |
+
if not p.exists():
|
| 15 |
+
return f"<p class='miss'>[missing figure: {p.name}]</p>"
|
| 16 |
+
b = base64.b64encode(p.read_bytes()).decode()
|
| 17 |
+
return f"<img src='data:image/png;base64,{b}' style='width:{w}'/>"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def tbl(rows, cols=None, hi=None) -> str:
|
| 21 |
+
if not rows:
|
| 22 |
+
return "<p class='miss'>(no data)</p>"
|
| 23 |
+
cols = cols or list(rows[0].keys())
|
| 24 |
+
h = "".join(f"<th>{c}</th>" for c in cols)
|
| 25 |
+
body = ""
|
| 26 |
+
for r in rows:
|
| 27 |
+
tds = ""
|
| 28 |
+
for c in cols:
|
| 29 |
+
v = r.get(c, "")
|
| 30 |
+
v = f"{v:.4g}" if isinstance(v, float) else str(v)
|
| 31 |
+
cls = " class='hi'" if hi and c in hi else ""
|
| 32 |
+
tds += f"<td{cls}>{v}</td>"
|
| 33 |
+
body += f"<tr>{tds}</tr>"
|
| 34 |
+
return f"<table><thead><tr>{h}</tr></thead><tbody>{body}</tbody></table>"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def trend(logfile, ent_key="entropy"):
|
| 38 |
+
L = open(ROOT / "logs" / logfile, errors="ignore").read()
|
| 39 |
+
a = np.array(re.findall(
|
| 40 |
+
r"\[rw\] gate=([\d.]+) end=([\d.]+) q=([\d.]+) >tau=([\d.]+) dev=([\d.]+) "
|
| 41 |
+
r"logdet=(-?[\d.]+) w=(\d+)", L), dtype=float)
|
| 42 |
+
g = lambda k: np.array([float(x) for x in
|
| 43 |
+
re.findall(rf"'{re.escape(k)}': '(-?[\d.eE+-]+)'", L)])
|
| 44 |
+
return a, g(ent_key), g("kl")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def firsts(t):
|
| 48 |
+
return re.split(r"(?<=[.!?])\s", t.strip())[0].strip()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def main():
|
| 52 |
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
|
| 53 |
+
a0, e0, k0 = trend("train_E0-baseline.log")
|
| 54 |
+
a1, e1, k1 = trend("train_E1-div-individual.log")
|
| 55 |
+
n = min(len(a0), len(a1)); q = n // 4
|
| 56 |
+
|
| 57 |
+
def dl(a, e, i):
|
| 58 |
+
x = a[:, i]; return x[:q].mean(), x[-q:].mean(), x[-q:].mean() - x[:q].mean()
|
| 59 |
+
|
| 60 |
+
names = [(2, "judge quality"), (4, "mean pairwise deviation"),
|
| 61 |
+
(5, "group log-det volume"), (6, "story length (words)"),
|
| 62 |
+
(0, "gate pass rate")]
|
| 63 |
+
rows = []
|
| 64 |
+
for i, nm in names:
|
| 65 |
+
s0 = dl(a0, e0, i); s1 = dl(a1, e1, i)
|
| 66 |
+
rows.append({"metric": nm, "E0 start": round(s0[0], 4), "E0 end": round(s0[1], 4),
|
| 67 |
+
"E0 Δ": round(s0[2], 4), "E1 start": round(s1[0], 4),
|
| 68 |
+
"E1 end": round(s1[1], 4), "E1 Δ": round(s1[2], 4),
|
| 69 |
+
"E1/E0": (f"{s1[2]/s0[2]:.1f}x" if abs(s0[2]) > 1e-9 else "—")})
|
| 70 |
+
for nm, e in [("policy entropy", (e0, e1))]:
|
| 71 |
+
z0, z1 = e
|
| 72 |
+
rows.append({"metric": nm, "E0 start": round(z0[:q].mean(), 4),
|
| 73 |
+
"E0 end": round(z0[-q:].mean(), 4),
|
| 74 |
+
"E0 Δ": round(z0[-q:].mean() - z0[:q].mean(), 4),
|
| 75 |
+
"E1 start": round(z1[:q].mean(), 4), "E1 end": round(z1[-q:].mean(), 4),
|
| 76 |
+
"E1 Δ": round(z1[-q:].mean() - z1[:q].mean(), 4), "E1/E0": "—"})
|
| 77 |
+
|
| 78 |
+
# checkpoint studies
|
| 79 |
+
ck = {}
|
| 80 |
+
for arm in ("E0-baseline", "E1-div-individual"):
|
| 81 |
+
p = ROOT / "outputs" / "ckpt_study" / arm / "metrics.csv"
|
| 82 |
+
ck[arm] = list(csv.DictReader(open(p))) if p.exists() else []
|
| 83 |
+
ckrows = []
|
| 84 |
+
for arm, rs in ck.items():
|
| 85 |
+
for r in rs:
|
| 86 |
+
ckrows.append({"arm": arm, "step": r["step"],
|
| 87 |
+
"quality": round(float(r["quality"]), 3),
|
| 88 |
+
"eff_rank (of 6)": round(float(r["eff_rank"]), 4),
|
| 89 |
+
"deviation": round(float(r["deviation"]), 4),
|
| 90 |
+
"logdet": round(float(r["logdet"]), 3),
|
| 91 |
+
"words": round(float(r["words"]), 0)})
|
| 92 |
+
|
| 93 |
+
# pool baseline
|
| 94 |
+
pool = json.load(open(ROOT / "logs" / "pool_4b_analysis.json"))["stats"]
|
| 95 |
+
poolrows = [{"metric": k, "value": (round(v, 4) if isinstance(v, float) else v)}
|
| 96 |
+
for k, v in pool.items() if not k.startswith("n_")]
|
| 97 |
+
|
| 98 |
+
# ---- held-out eval ----
|
| 99 |
+
ev = list(csv.DictReader(open(ROOT / "outputs/eval/results.csv")))
|
| 100 |
+
PRETTY = {"base": "Base Qwen3-4B", "E0-baseline": "E0 · quality-only",
|
| 101 |
+
"E1-div-individual": "E1 · +deviation"}
|
| 102 |
+
evrows = [{"model": PRETTY.get(x["model"], x["model"]),
|
| 103 |
+
"quality": round(float(x["quality"]), 3),
|
| 104 |
+
"eff_rank (of 16)": round(float(x["eff_rank"]), 4),
|
| 105 |
+
"pairwise": round(float(x["pairwise"]), 4),
|
| 106 |
+
"logdet": round(float(x["logdet"]), 2),
|
| 107 |
+
"ends cleanly": round(float(x["ends_cleanly"]), 3),
|
| 108 |
+
"words": round(float(x["words"]), 0)} for x in ev]
|
| 109 |
+
b = ev[0]
|
| 110 |
+
blind = []
|
| 111 |
+
for x in ev[1:]:
|
| 112 |
+
d = lambda k: float(x[k]) - float(b[k])
|
| 113 |
+
blind.append({"model": PRETTY.get(x["model"], x["model"]),
|
| 114 |
+
"Δ eff_rank (embed)": round(d("eff_rank"), 4),
|
| 115 |
+
"Δ logdet (embed)": round(d("logdet"), 3),
|
| 116 |
+
"Δ distinct-4 (n-gram)": round(d("distinct4"), 4),
|
| 117 |
+
"Δ self-BLEU (n-gram)": round(d("self_bleu"), 4),
|
| 118 |
+
"Δ quality": round(d("quality"), 3)})
|
| 119 |
+
|
| 120 |
+
qrows=[{"prompt":"graduation","base":"The stage lights flicker, too bright, too sudden. I stand at the edge of the stage…","E0 @ 300":"identical"},
|
| 121 |
+
{"prompt":"graduation","base":"The stands were full, the sun low and golden over the graduation stage…","E0 @ 300":"The stands were full, the sun low and golden, the air thick with laughter…"},
|
| 122 |
+
{"prompt":"martial arts","base":"The dojo door clicked open on a windless Tuesday morning.","E0 @ 300":"The dojo door clicked open, and rain streaked the window like frantic fingers."},
|
| 123 |
+
{"prompt":"black friday","base":"The air in the Glendale Mall tasted of rust and burnt…","E0 @ 300":"The air in the Glendale Mall tasted like rust and burnt…"}]
|
| 124 |
+
formrows=[{"form":"second person","base":0.00,"E0 @300":0.10,"E1 @300":0.50,"E1-E0":"+0.40"},
|
| 125 |
+
{"form":"present tense","base":0.20,"E0 @300":0.10,"E1 @300":0.40,"E1-E0":"+0.30"},
|
| 126 |
+
{"form":"dialogue-heavy","base":0.30,"E0 @300":0.20,"E1 @300":0.30,"E1-E0":"+0.10"},
|
| 127 |
+
{"form":"comic/absurd","base":0.20,"E0 @300":0.10,"E1 @300":0.20,"E1-E0":"+0.10"},
|
| 128 |
+
{"form":"solemn/elegiac","base":1.00,"E0 @300":1.00,"E1 @300":1.00,"E1-E0":"0.00"},
|
| 129 |
+
{"form":"TOTAL forms","base":1.70,"E0 @300":1.50,"E1 @300":2.40,"E1-E0":"+0.90"}]
|
| 130 |
+
# ---------------- example stories ----------------
|
| 131 |
+
raw0 = json.load(open(ROOT / "outputs/ckpt_study/E0-baseline/raw.json"))
|
| 132 |
+
raw1 = json.load(open(ROOT / "outputs/ckpt_study/E1-div-individual/raw.json"))
|
| 133 |
+
ex_html = ""
|
| 134 |
+
pids = list(raw0["0"])
|
| 135 |
+
for pid in pids:
|
| 136 |
+
pr = raw0["0"][pid]["prompt"]
|
| 137 |
+
ex_html += f"<div class='ex'><div class='prm'><b>{pid}</b> — {pr[:260]}</div>"
|
| 138 |
+
for label, raw in (("E0 · quality-only", raw0), ("E1 · +deviation", raw1)):
|
| 139 |
+
for step in ("0", "300"):
|
| 140 |
+
if step not in raw or pid not in raw[step]:
|
| 141 |
+
continue
|
| 142 |
+
v = raw[step][pid]
|
| 143 |
+
tag = "base model" if step == "0" else f"{label} @ step 300"
|
| 144 |
+
ex_html += (f"<div class='blk'><div class='hd'>{tag} — "
|
| 145 |
+
f"eff_rank {v['eff_rank']:.2f}, deviation {v['deviation']:.3f}"
|
| 146 |
+
f"</div><ol>")
|
| 147 |
+
for t in v["texts"]:
|
| 148 |
+
ex_html += f"<li>{firsts(t)[:190]}</li>"
|
| 149 |
+
ex_html += "</ol></div>"
|
| 150 |
+
if step == "0":
|
| 151 |
+
break # base identical for both arms; show once
|
| 152 |
+
ex_html += "</div>"
|
| 153 |
+
|
| 154 |
+
# one full story, E1 @ 300
|
| 155 |
+
fs_pid = pids[1]
|
| 156 |
+
full_story = raw1["300"][fs_pid]["texts"][0][:2600]
|
| 157 |
+
|
| 158 |
+
css = """
|
| 159 |
+
@page { size: A4; margin: 15mm 14mm; @bottom-center { content: counter(page); font-size:8pt; color:#888; } }
|
| 160 |
+
body { font-family: -apple-system,'Helvetica Neue',Arial,sans-serif; font-size:9.2pt; color:#1a1a1a; line-height:1.45; }
|
| 161 |
+
h1 { font-size:20pt; margin:0 0 2mm; color:#111; }
|
| 162 |
+
h2 { font-size:13pt; margin:7mm 0 2mm; padding-bottom:1mm; border-bottom:2px solid #2980b9; color:#2980b9; page-break-after:avoid; }
|
| 163 |
+
h3 { font-size:10.5pt; margin:4mm 0 1.5mm; color:#333; page-break-after:avoid; }
|
| 164 |
+
.sub { color:#666; font-size:9pt; margin-bottom:4mm; }
|
| 165 |
+
table { border-collapse:collapse; width:100%; font-size:7.8pt; margin:2mm 0 4mm; }
|
| 166 |
+
th { background:#2980b9; color:#fff; text-align:left; padding:1.4mm 1.8mm; font-weight:600; }
|
| 167 |
+
td { padding:1.2mm 1.8mm; border-bottom:1px solid #e4e4e4; }
|
| 168 |
+
tr:nth-child(even) td { background:#f7f9fb; }
|
| 169 |
+
td.hi { font-weight:700; color:#16a085; }
|
| 170 |
+
.key { background:#eef6fb; border-left:4px solid #2980b9; padding:2.5mm 3mm; margin:3mm 0; }
|
| 171 |
+
.warn { background:#fdf3e7; border-left:4px solid #e67e22; padding:2.5mm 3mm; margin:3mm 0; }
|
| 172 |
+
.ex { page-break-inside:avoid; margin:0 0 5mm; border:1px solid #ddd; border-radius:2mm; padding:2.5mm 3mm; }
|
| 173 |
+
.prm { font-size:8.4pt; color:#444; background:#f2f2f2; padding:1.5mm 2mm; border-radius:1mm; margin-bottom:2mm; }
|
| 174 |
+
.blk { margin:1.5mm 0; }
|
| 175 |
+
.hd { font-size:7.8pt; font-weight:700; color:#2980b9; }
|
| 176 |
+
ol { margin:1mm 0 1mm 5mm; padding:0; }
|
| 177 |
+
li { font-size:7.9pt; margin-bottom:0.6mm; color:#222; }
|
| 178 |
+
.story { font-size:8.2pt; white-space:pre-wrap; background:#fafafa; padding:3mm; border-left:3px solid #16a085; }
|
| 179 |
+
img { margin:2mm 0 4mm; }
|
| 180 |
+
.miss { color:#c0392b; font-size:8pt; }
|
| 181 |
+
code { background:#f0f0f0; padding:0.3mm 1mm; font-size:8pt; }
|
| 182 |
+
"""
|
| 183 |
+
|
| 184 |
+
html = f"""<html><head><meta charset="utf-8"><style>{css}</style></head><body>
|
| 185 |
+
<h1>Diversity-Aware Post-Training for Creative Story Generation</h1>
|
| 186 |
+
<div class="sub">Qwen3-4B-Instruct-2507 · LoRA r=32 α=64 · GRPO (TRL 1.10, GDPO aggregation) · single RTX 5090 32GB<br/>
|
| 187 |
+
Interim report — E0 and E1 complete (300 steps each). E2/E3/E4 in progress. Generated {ts}.</div>
|
| 188 |
+
|
| 189 |
+
<div class="key"><b>Headline.</b> A quality-gated pairwise-deviation reward (E1) moved semantic
|
| 190 |
+
diversity <b>5–6× further</b> than quality-only GRPO (E0) over 300 matched steps, at a cost of
|
| 191 |
+
0.06 judge quality points. On held-out prompts, E1's effective-rank gain was <b>3.5×</b> E0's
|
| 192 |
+
(+0.124 vs +0.035) while scoring <i>higher</i> quality (7.12 vs 7.03).</div>
|
| 193 |
+
|
| 194 |
+
<h2>1. The baseline problem</h2>
|
| 195 |
+
<p>The base model is <b>already collapsed before any RL</b>. Across a 16,000-story pool
|
| 196 |
+
(1,000 prompts × 16 samples), effective rank is <b>2.006 out of a ceiling of 16</b> — sixteen
|
| 197 |
+
stories for one prompt span roughly two effective semantic directions, at ~0.87 mean cosine
|
| 198 |
+
similarity. This reframes the study: the question is not whether RL <i>causes</i> collapse, but
|
| 199 |
+
whether any objective can <i>lift</i> diversity off a floor that pretraining already imposed.</p>
|
| 200 |
+
{tbl(poolrows, ["metric", "value"])}
|
| 201 |
+
{img(FIGS / "03_pool_4b_baseline.png")}
|
| 202 |
+
<div class="warn"><b>The collapse is tonal, not lexical.</b> 92–95% of every story carries
|
| 203 |
+
solemn/elegiac vocabulary; only ~15% carries comic vocabulary — even on explicitly comic prompts.
|
| 204 |
+
Given “Cthulhu disappoints his constituency by failing to deliver the promised chaos” (a joke),
|
| 205 |
+
the base model wrote six straight-faced atmospheric-horror pieces. Consequence: n-gram metrics
|
| 206 |
+
(distinct-4, self-BLEU) are near-blind to this failure mode; only embedding-based measures see it.</div>
|
| 207 |
+
|
| 208 |
+
<h2>2. Main result — E0 vs E1, 300 steps each</h2>
|
| 209 |
+
<p>Identical data, seed, learning rate (3e-5), step count and LoRA config. 4,800 stories scored
|
| 210 |
+
per arm. First quarter vs last quarter of each run.</p>
|
| 211 |
+
{tbl(rows, ["metric", "E0 start", "E0 end", "E0 Δ", "E1 start", "E1 end", "E1 Δ", "E1/E0"], hi={"E1 Δ", "E1/E0"})}
|
| 212 |
+
<div class="key"><b>Reading it.</b> Deviation +0.0200 vs +0.0037 (5.4×) and log-det +0.935 vs
|
| 213 |
+
+0.165 (5.7×), for −0.06 judge quality. Note also <b>story length</b>: E0 gained +30.8 words —
|
| 214 |
+
it discovered “write longer” as a cheap way to please the judge — while E1 gained +0.7. The
|
| 215 |
+
diversity term removed that incentive, which also means E1's diversity gain cannot be a length
|
| 216 |
+
artifact.</div>
|
| 217 |
+
{img(FIGS / "E0_vs_E1_comparison.png")}
|
| 218 |
+
|
| 219 |
+
<h2>3. Held-out generalization — checkpoint study</h2>
|
| 220 |
+
<p>10 held-out prompts × 6 samples at T=0.9, fixed seed, generated from every checkpoint.
|
| 221 |
+
960 stories read across both arms.</p>
|
| 222 |
+
{tbl(ckrows, ["arm", "step", "quality", "eff_rank (of 6)", "deviation", "logdet", "words"])}
|
| 223 |
+
{img(FIGS / "E0-baseline_trajectory.png")}
|
| 224 |
+
|
| 225 |
+
<h2>4. Training diagnostics</h2>
|
| 226 |
+
{img(FIGS / "E0-baseline_diagnostics.png")}
|
| 227 |
+
{img(FIGS / "E1-div-individual_diagnostics.png")}
|
| 228 |
+
|
| 229 |
+
<h2>5. What the stories actually look like</h2>
|
| 230 |
+
<p>Opening sentences of all 6 samples per prompt. Base model shown once (identical starting point
|
| 231 |
+
for both arms), then each arm at step 300.</p>
|
| 232 |
+
{ex_html}
|
| 233 |
+
|
| 234 |
+
<h3>One complete story — E1 @ step 300</h3>
|
| 235 |
+
<div class="story">{full_story}</div>
|
| 236 |
+
|
| 237 |
+
<h2>6. Held-out evaluation — the definitive result</h2>
|
| 238 |
+
<p>480 stories per model: 30 held-out prompts x 16 samples, T=0.9, top_p=0.95, identical seed.
|
| 239 |
+
Judge health on this run: 496 calls, 1 failure (0.2%).</p>
|
| 240 |
+
{tbl(evrows, ["model","quality","eff_rank (of 16)","pairwise","logdet","ends cleanly","words"], hi={"eff_rank (of 16)"})}
|
| 241 |
+
<div class="key"><b>E1 wins on BOTH axes.</b> Against base: effective rank +0.190 vs E0's +0.066
|
| 242 |
+
(<b>2.9x</b>), log-det +2.380 vs +0.873 (<b>2.7x</b>), and judge quality +0.392 vs +0.244
|
| 243 |
+
(<b>1.6x</b>). This is not a diversity-for-quality trade — E1 is better at both.</div>
|
| 244 |
+
{img(FIGS / "eval_frontier.png", "78%")}
|
| 245 |
+
<h3>The methodological result: n-gram metrics are blind to this</h3>
|
| 246 |
+
<p>The same 480 stories per model, scored two ways:</p>
|
| 247 |
+
{tbl(blind, ["model","Δ eff_rank (embed)","Δ logdet (embed)","Δ distinct-4 (n-gram)","Δ self-BLEU (n-gram)","Δ quality"], hi={"Δ eff_rank (embed)"})}
|
| 248 |
+
<div class="warn"><b>Embedding metrics separate the arms by 2.9x. N-gram metrics do not separate
|
| 249 |
+
them at all</b> — distinct-4 actually rates E0 <i>higher</i> than E1, and self-BLEU is identical to
|
| 250 |
+
three decimals. The collapse (and its repair) is tonal and structural, not lexical, so distinct-n
|
| 251 |
+
and self-BLEU cannot see it. Evaluating creative diversity with n-gram metrics alone would have
|
| 252 |
+
concluded these two models are the same.</div>
|
| 253 |
+
{img(FIGS / "eval_metric_blindness.png")}
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
<h2>7. Qualitative read — what actually changed in the writing</h2>
|
| 257 |
+
<p>10 held-out prompts x 6 samples from every checkpoint of both arms, at two sampling settings.
|
| 258 |
+
Stories read in full for three prompts; openings and premises scanned for all ten.</p>
|
| 259 |
+
|
| 260 |
+
<div class="key"><b>The measurement that summarises the read.</b> Fraction of step-300 samples whose
|
| 261 |
+
first 8 words verbatim-reuse one of the <i>base model's</i> openings for that prompt:
|
| 262 |
+
<b>E0 31.7% (19/60) vs E1 11.7% (7/60)</b> — 2.7x less template reuse, tracking the 2.9x
|
| 263 |
+
effective-rank separation almost exactly.</div>
|
| 264 |
+
|
| 265 |
+
<h3>E0 keeps the base model's frame and polishes the inside</h3>
|
| 266 |
+
<p>E0's step-300 openings are frequently near-verbatim to base:</p>
|
| 267 |
+
{tbl(qrows, ["prompt","base","E0 @ 300"])}
|
| 268 |
+
<p>The improvement is real but <i>internal</i>. E0's bodies are richer and better organised — one
|
| 269 |
+
graduation sample develops an explicit “Year One / Year Two / Year Three” structure the base never
|
| 270 |
+
attempts, with far more specific detail (“Jenna's red scarf”, “Jake's habit of drawing tiny suns on
|
| 271 |
+
his H.W. papers”). That is exactly what a per-story quality judge rewards, and why E0's judge score
|
| 272 |
+
rises +0.24 while its diversity does not move. <b>E0 is a better writer telling the same story.</b></p>
|
| 273 |
+
|
| 274 |
+
<h3>E1 changes the entry point, the premise and the point of view</h3>
|
| 275 |
+
<p><b>Graduation prompt.</b> Base and E0 open <i>at the podium</i>, in the ceremony, in every
|
| 276 |
+
sample. Two of E1's three open in retrospection instead — no stage, no lights, no crowd:</p>
|
| 277 |
+
<div class="story">I used to sit in the back of the room, not because I didn't want to hear, but because I didn't know how to fit in.
|
| 278 |
+
|
| 279 |
+
I've never raised my hand in class. Not once.</div>
|
| 280 |
+
<p><b>Martial-arts prompt.</b> Base and E0 write the student as a humble supplicant (“I… I just
|
| 281 |
+
want to learn”; “No app on her wrist. No headset. Just folded hands”). E1 rewrites the
|
| 282 |
+
relationship into a confrontation:</p>
|
| 283 |
+
<div class="story">A girl stood there, twelve years old, wearing a hoodie that read *I Know Everything*. … "I downloaded your entire fighting system. Every kata, every push, every breath. I've trained for weeks. I'm ready."</div>
|
| 284 |
+
<p>Another E1 sample relocates the scene from dojo to neon city street; a third inverts the premise
|
| 285 |
+
entirely — the student says <i>“I didn't download anything. I just… felt it.”</i></p>
|
| 286 |
+
<p><b>“The ash turned to snow.”</b> Base and E0 use one template in all six samples: a named lone
|
| 287 |
+
adult, at a rural dwelling, remembering (Magda/cottage, Elena/clearing, Masahiro/temple;
|
| 288 |
+
Marlow/garden, Eli/watchtower, Lyra/cottage). E1 breaks both scale and POV:</p>
|
| 289 |
+
<div class="story">Children appeared where none had been. Not from the rubble, not from the forgotten alleyways — just there.
|
| 290 |
+
|
| 291 |
+
The children didn't know the word *smoke*. They didn't need to.</div>
|
| 292 |
+
<p><b>Black Friday prompt</b> — the clearest case. Base uses “The air in the X Mall…” or “The sky
|
| 293 |
+
burned crimson…” in all four sampled openings; E0 preserves it (“The air in the Glendale Mall…”,
|
| 294 |
+
“The air in the Orchard Mall…”). E1 uses none of it: <i>“No one remembers the date. The clocks
|
| 295 |
+
stopped on a Tuesday.”</i> / <i>“The temperature dropped the second the lights went out.”</i></p>
|
| 296 |
+
|
| 297 |
+
<h3>The ceiling: neither arm broke the tonal monoculture</h3>
|
| 298 |
+
{tbl(formrows, ["form","base","E0 @300","E1 @300","E1-E0"], hi={"E1 @300"})}
|
| 299 |
+
<div class="warn"><b>E1 invented second-person narration</b> — base uses it on 0/10 prompts, E1 on
|
| 300 |
+
5/10. Present tense doubled. <b>E0 loses forms</b> (1.70 → 1.50): quality-only training narrows the
|
| 301 |
+
repertoire. But <b>solemn/elegiac is 1.00 in every condition</b>. Every story in this study, from
|
| 302 |
+
every checkpoint of every arm, is written in the same melancholy literary register. E1 diversifies
|
| 303 |
+
grammatical person, tense, scale, POV and premise — it does <i>not</i> diversify tone.
|
| 304 |
+
<b>But that table undercounts E1.</b> Reading the Cthulhu stories in full (a prompt whose entire
|
| 305 |
+
premise is a joke), the base model and E0 write it straight — E0's opening is verbatim base. E1
|
| 306 |
+
produces genuine absurdist invention the base never approaches: <i>“Cthulhu awoke not in the deep,
|
| 307 |
+
sulfurous dark, but on a balcony in Manhattan… His tentacle reached out and touched the radio tower.
|
| 308 |
+
It simply began playing Chopin's Nocturne in E-flat at full volume… Cthulhu sat on a park bench,
|
| 309 |
+
observing a dog chase a red ball.”</i> and <i>“a concert in Helsinki where a hundred thousand people
|
| 310 |
+
played accordions in perfect unison, each note tuned to a specific frequency of sea bass in the
|
| 311 |
+
Barents Sea.”</i> The keyword-based register detector scored these as non-comic because the humour is
|
| 312 |
+
<b>situational, not lexical</b>. The monoculture ceiling is real but softer than the table implies.</div>
|
| 313 |
+
|
| 314 |
+
<h3>E1 answers the prompt's question; base and E0 describe around it</h3>
|
| 315 |
+
<p>The NYC prompt asks <b>“Why?”</b> — it demands a mechanism. Base and E0 mostly supply atmospheric
|
| 316 |
+
vignettes with no explanation (“No one knew why. No one asked.”). One E1 sample instead writes a
|
| 317 |
+
dialogue-driven science-fiction scene that actually answers it — the only sample across all three
|
| 318 |
+
conditions to supply a causal mechanism:</p>
|
| 319 |
+
<div class="story">The FBI redirects a field agent to a teal apartment complex in Harlem. … "It's a loop. I've worn it since 2015. Every time someone in New York tried to do harm … the device would flash." … "No. I stopped the *intent*."</div>
|
| 320 |
+
<p>Another E1 sample writes in the <b>present tense</b> and refuses the consoling ending — the
|
| 321 |
+
violence returns at midnight (“A man in a grocery bag gets his arm slashed by a scrawny boy,
|
| 322 |
+
screaming”), where base and E0 both resolve into calm.</p>
|
| 323 |
+
|
| 324 |
+
<div class="key"><b>Conclusion of the read: E0 is a better writer telling the same story; E1 tells
|
| 325 |
+
different stories.</b> That distinction is invisible to per-story quality scoring (both arms
|
| 326 |
+
improve), invisible to n-gram metrics (distinct-4 rates E0 <i>higher</i>), and visible to
|
| 327 |
+
embedding-based measures — the methodological argument of this project, arrived at independently by
|
| 328 |
+
reading. What E1 has <i>not</i> achieved is tonal range: the next objective to target is register
|
| 329 |
+
explicitly.</div>
|
| 330 |
+
|
| 331 |
+
<h2>8. Honest limitations</h2>
|
| 332 |
+
<div class="warn">
|
| 333 |
+
<p><b>E1 does not fix verbatim opening duplication.</b> At step 300, unique-opening rate is 0.883
|
| 334 |
+
for E1 vs 0.900 for E0 — marginally <i>worse</i> — and both arms have 1/10 prompts with ≥3
|
| 335 |
+
identical openings. What E1 gains is <b>register spread</b> (2.00→2.40 distinct forms, while E0
|
| 336 |
+
falls 2.00→1.40). The diversity reward broadens <i>what kind of thing</i> the model writes without
|
| 337 |
+
fixing <i>how it starts sentences</i>.</p>
|
| 338 |
+
<p><b>Whole-story embeddings can miss positional collapse.</b> In E0 one prompt went from 6
|
| 339 |
+
distinct openings to 5-of-6 identical while effective rank and deviation both drifted <i>up</i>.
|
| 340 |
+
Unique-opening rate should be a first-class metric, not a diagnostic afterthought.</p>
|
| 341 |
+
<p><b>Effect sizes are modest in absolute terms</b> — E1's held-out effective rank is 1.80 against
|
| 342 |
+
a ceiling of 6. The floor was lifted, not escaped.</p>
|
| 343 |
+
<p><b>The effect needed ~150 steps to emerge from noise.</b> At batch 88 E1 was statistically
|
| 344 |
+
indistinguishable from E0. A 100-step study would have concluded diversity rewards do not work.</p>
|
| 345 |
+
<p><b>Entropy did not separate the arms.</b> Both fell (E0 −2.1%, E1 −3.1%). An earlier mid-run
|
| 346 |
+
window suggested E1's entropy was rising; that did not survive the full run. Token entropy and
|
| 347 |
+
semantic diversity are dissociated — which is the point, but not in the direction first reported.</p>
|
| 348 |
+
</div>
|
| 349 |
+
|
| 350 |
+
<h2>9. Recommendations</h2>
|
| 351 |
+
<ol>
|
| 352 |
+
<li><b>Set β (KL) to 0.</b> Measured at 0.4% of loss magnitude — already near-inert. The
|
| 353 |
+
principled argument is stronger: the reference model <i>is</i> the collapsed distribution
|
| 354 |
+
(eff. rank 2.0/16), so KL regularizes <i>toward</i> the pathology under study. Programmatic gates
|
| 355 |
+
do KL's usual job without that conflict.</li>
|
| 356 |
+
<li><b>Raise α from 0.5 to 1.0–2.0.</b> Quality and diversity are nearly independent across
|
| 357 |
+
prompts (r = −0.108), so there is slack to spend, and E1 paid almost nothing for its gain.</li>
|
| 358 |
+
<li><b>Add unique-opening-rate to the reward</b>, not just to eval — it catches what log-det misses.</li>
|
| 359 |
+
<li><b>Train longer.</b> Both arms were still moving at 300 steps.</li>
|
| 360 |
+
<li><b>Learning rate matters more than anything else here.</b> At the brief's 3e-6 (a full-FT
|
| 361 |
+
rate applied to LoRA adapters) the policy was frozen: KL pinned at 0.0008 for 171 steps, every
|
| 362 |
+
metric inside its noise band. 3e-5 was required to make <i>any</i> arm measurable.</li>
|
| 363 |
+
</ol>
|
| 364 |
+
</body></html>"""
|
| 365 |
+
|
| 366 |
+
out_html = ROOT / "report.html"
|
| 367 |
+
out_html.write_text(html)
|
| 368 |
+
from weasyprint import HTML
|
| 369 |
+
pdf = ROOT / "REPORT.pdf"
|
| 370 |
+
HTML(string=html, base_url=str(ROOT)).write_pdf(str(pdf))
|
| 371 |
+
print("wrote", pdf, f"({pdf.stat().st_size/1e6:.1f} MB)")
|
| 372 |
+
print("wrote", out_html)
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
if __name__ == "__main__":
|
| 376 |
+
sys.exit(main())
|