creative-writing-llm / src /make_pdf.py
Pranav2748's picture
add PDF report generator
471fe4d verified
Raw
History Blame Contribute Delete
23.4 kB
"""Build the final PDF report: metrics, figures, and example stories."""
from __future__ import annotations
import base64, csv, json, re, sys
from collections import Counter
from datetime import datetime
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parent.parent
FIGS = ROOT / "logs" / "figures"
def img(p: Path, w="100%") -> str:
if not p.exists():
return f"<p class='miss'>[missing figure: {p.name}]</p>"
b = base64.b64encode(p.read_bytes()).decode()
return f"<img src='data:image/png;base64,{b}' style='width:{w}'/>"
def tbl(rows, cols=None, hi=None) -> str:
if not rows:
return "<p class='miss'>(no data)</p>"
cols = cols or list(rows[0].keys())
h = "".join(f"<th>{c}</th>" for c in cols)
body = ""
for r in rows:
tds = ""
for c in cols:
v = r.get(c, "")
v = f"{v:.4g}" if isinstance(v, float) else str(v)
cls = " class='hi'" if hi and c in hi else ""
tds += f"<td{cls}>{v}</td>"
body += f"<tr>{tds}</tr>"
return f"<table><thead><tr>{h}</tr></thead><tbody>{body}</tbody></table>"
def trend(logfile, ent_key="entropy"):
L = open(ROOT / "logs" / logfile, errors="ignore").read()
a = np.array(re.findall(
r"\[rw\] gate=([\d.]+) end=([\d.]+) q=([\d.]+) >tau=([\d.]+) dev=([\d.]+) "
r"logdet=(-?[\d.]+) w=(\d+)", L), dtype=float)
g = lambda k: np.array([float(x) for x in
re.findall(rf"'{re.escape(k)}': '(-?[\d.eE+-]+)'", L)])
return a, g(ent_key), g("kl")
def firsts(t):
return re.split(r"(?<=[.!?])\s", t.strip())[0].strip()
def main():
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
a0, e0, k0 = trend("train_E0-baseline.log")
a1, e1, k1 = trend("train_E1-div-individual.log")
n = min(len(a0), len(a1)); q = n // 4
def dl(a, e, i):
x = a[:, i]; return x[:q].mean(), x[-q:].mean(), x[-q:].mean() - x[:q].mean()
names = [(2, "judge quality"), (4, "mean pairwise deviation"),
(5, "group log-det volume"), (6, "story length (words)"),
(0, "gate pass rate")]
rows = []
for i, nm in names:
s0 = dl(a0, e0, i); s1 = dl(a1, e1, i)
rows.append({"metric": nm, "E0 start": round(s0[0], 4), "E0 end": round(s0[1], 4),
"E0 Δ": round(s0[2], 4), "E1 start": round(s1[0], 4),
"E1 end": round(s1[1], 4), "E1 Δ": round(s1[2], 4),
"E1/E0": (f"{s1[2]/s0[2]:.1f}x" if abs(s0[2]) > 1e-9 else "—")})
for nm, e in [("policy entropy", (e0, e1))]:
z0, z1 = e
rows.append({"metric": nm, "E0 start": round(z0[:q].mean(), 4),
"E0 end": round(z0[-q:].mean(), 4),
"E0 Δ": round(z0[-q:].mean() - z0[:q].mean(), 4),
"E1 start": round(z1[:q].mean(), 4), "E1 end": round(z1[-q:].mean(), 4),
"E1 Δ": round(z1[-q:].mean() - z1[:q].mean(), 4), "E1/E0": "—"})
# checkpoint studies
ck = {}
for arm in ("E0-baseline", "E1-div-individual"):
p = ROOT / "outputs" / "ckpt_study" / arm / "metrics.csv"
ck[arm] = list(csv.DictReader(open(p))) if p.exists() else []
ckrows = []
for arm, rs in ck.items():
for r in rs:
ckrows.append({"arm": arm, "step": r["step"],
"quality": round(float(r["quality"]), 3),
"eff_rank (of 6)": round(float(r["eff_rank"]), 4),
"deviation": round(float(r["deviation"]), 4),
"logdet": round(float(r["logdet"]), 3),
"words": round(float(r["words"]), 0)})
# pool baseline
pool = json.load(open(ROOT / "logs" / "pool_4b_analysis.json"))["stats"]
poolrows = [{"metric": k, "value": (round(v, 4) if isinstance(v, float) else v)}
for k, v in pool.items() if not k.startswith("n_")]
# ---- held-out eval ----
ev = list(csv.DictReader(open(ROOT / "outputs/eval/results.csv")))
PRETTY = {"base": "Base Qwen3-4B", "E0-baseline": "E0 · quality-only",
"E1-div-individual": "E1 · +deviation"}
evrows = [{"model": PRETTY.get(x["model"], x["model"]),
"quality": round(float(x["quality"]), 3),
"eff_rank (of 16)": round(float(x["eff_rank"]), 4),
"pairwise": round(float(x["pairwise"]), 4),
"logdet": round(float(x["logdet"]), 2),
"ends cleanly": round(float(x["ends_cleanly"]), 3),
"words": round(float(x["words"]), 0)} for x in ev]
b = ev[0]
blind = []
for x in ev[1:]:
d = lambda k: float(x[k]) - float(b[k])
blind.append({"model": PRETTY.get(x["model"], x["model"]),
"Δ eff_rank (embed)": round(d("eff_rank"), 4),
"Δ logdet (embed)": round(d("logdet"), 3),
"Δ distinct-4 (n-gram)": round(d("distinct4"), 4),
"Δ self-BLEU (n-gram)": round(d("self_bleu"), 4),
"Δ quality": round(d("quality"), 3)})
qrows=[{"prompt":"graduation","base":"The stage lights flicker, too bright, too sudden. I stand at the edge of the stage…","E0 @ 300":"identical"},
{"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…"},
{"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."},
{"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…"}]
formrows=[{"form":"second person","base":0.00,"E0 @300":0.10,"E1 @300":0.50,"E1-E0":"+0.40"},
{"form":"present tense","base":0.20,"E0 @300":0.10,"E1 @300":0.40,"E1-E0":"+0.30"},
{"form":"dialogue-heavy","base":0.30,"E0 @300":0.20,"E1 @300":0.30,"E1-E0":"+0.10"},
{"form":"comic/absurd","base":0.20,"E0 @300":0.10,"E1 @300":0.20,"E1-E0":"+0.10"},
{"form":"solemn/elegiac","base":1.00,"E0 @300":1.00,"E1 @300":1.00,"E1-E0":"0.00"},
{"form":"TOTAL forms","base":1.70,"E0 @300":1.50,"E1 @300":2.40,"E1-E0":"+0.90"}]
# ---------------- example stories ----------------
raw0 = json.load(open(ROOT / "outputs/ckpt_study/E0-baseline/raw.json"))
raw1 = json.load(open(ROOT / "outputs/ckpt_study/E1-div-individual/raw.json"))
ex_html = ""
pids = list(raw0["0"])
for pid in pids:
pr = raw0["0"][pid]["prompt"]
ex_html += f"<div class='ex'><div class='prm'><b>{pid}</b> — {pr[:260]}</div>"
for label, raw in (("E0 · quality-only", raw0), ("E1 · +deviation", raw1)):
for step in ("0", "300"):
if step not in raw or pid not in raw[step]:
continue
v = raw[step][pid]
tag = "base model" if step == "0" else f"{label} @ step 300"
ex_html += (f"<div class='blk'><div class='hd'>{tag} — "
f"eff_rank {v['eff_rank']:.2f}, deviation {v['deviation']:.3f}"
f"</div><ol>")
for t in v["texts"]:
ex_html += f"<li>{firsts(t)[:190]}</li>"
ex_html += "</ol></div>"
if step == "0":
break # base identical for both arms; show once
ex_html += "</div>"
# one full story, E1 @ 300
fs_pid = pids[1]
full_story = raw1["300"][fs_pid]["texts"][0][:2600]
css = """
@page { size: A4; margin: 15mm 14mm; @bottom-center { content: counter(page); font-size:8pt; color:#888; } }
body { font-family: -apple-system,'Helvetica Neue',Arial,sans-serif; font-size:9.2pt; color:#1a1a1a; line-height:1.45; }
h1 { font-size:20pt; margin:0 0 2mm; color:#111; }
h2 { font-size:13pt; margin:7mm 0 2mm; padding-bottom:1mm; border-bottom:2px solid #2980b9; color:#2980b9; page-break-after:avoid; }
h3 { font-size:10.5pt; margin:4mm 0 1.5mm; color:#333; page-break-after:avoid; }
.sub { color:#666; font-size:9pt; margin-bottom:4mm; }
table { border-collapse:collapse; width:100%; font-size:7.8pt; margin:2mm 0 4mm; }
th { background:#2980b9; color:#fff; text-align:left; padding:1.4mm 1.8mm; font-weight:600; }
td { padding:1.2mm 1.8mm; border-bottom:1px solid #e4e4e4; }
tr:nth-child(even) td { background:#f7f9fb; }
td.hi { font-weight:700; color:#16a085; }
.key { background:#eef6fb; border-left:4px solid #2980b9; padding:2.5mm 3mm; margin:3mm 0; }
.warn { background:#fdf3e7; border-left:4px solid #e67e22; padding:2.5mm 3mm; margin:3mm 0; }
.ex { page-break-inside:avoid; margin:0 0 5mm; border:1px solid #ddd; border-radius:2mm; padding:2.5mm 3mm; }
.prm { font-size:8.4pt; color:#444; background:#f2f2f2; padding:1.5mm 2mm; border-radius:1mm; margin-bottom:2mm; }
.blk { margin:1.5mm 0; }
.hd { font-size:7.8pt; font-weight:700; color:#2980b9; }
ol { margin:1mm 0 1mm 5mm; padding:0; }
li { font-size:7.9pt; margin-bottom:0.6mm; color:#222; }
.story { font-size:8.2pt; white-space:pre-wrap; background:#fafafa; padding:3mm; border-left:3px solid #16a085; }
img { margin:2mm 0 4mm; }
.miss { color:#c0392b; font-size:8pt; }
code { background:#f0f0f0; padding:0.3mm 1mm; font-size:8pt; }
"""
html = f"""<html><head><meta charset="utf-8"><style>{css}</style></head><body>
<h1>Diversity-Aware Post-Training for Creative Story Generation</h1>
<div class="sub">Qwen3-4B-Instruct-2507 · LoRA r=32 α=64 · GRPO (TRL 1.10, GDPO aggregation) · single RTX 5090 32GB<br/>
Interim report — E0 and E1 complete (300 steps each). E2/E3/E4 in progress. Generated {ts}.</div>
<div class="key"><b>Headline.</b> A quality-gated pairwise-deviation reward (E1) moved semantic
diversity <b>5–6× further</b> than quality-only GRPO (E0) over 300 matched steps, at a cost of
0.06 judge quality points. On held-out prompts, E1's effective-rank gain was <b>3.5×</b> E0's
(+0.124 vs +0.035) while scoring <i>higher</i> quality (7.12 vs 7.03).</div>
<h2>1. The baseline problem</h2>
<p>The base model is <b>already collapsed before any RL</b>. Across a 16,000-story pool
(1,000 prompts × 16 samples), effective rank is <b>2.006 out of a ceiling of 16</b> — sixteen
stories for one prompt span roughly two effective semantic directions, at ~0.87 mean cosine
similarity. This reframes the study: the question is not whether RL <i>causes</i> collapse, but
whether any objective can <i>lift</i> diversity off a floor that pretraining already imposed.</p>
{tbl(poolrows, ["metric", "value"])}
{img(FIGS / "03_pool_4b_baseline.png")}
<div class="warn"><b>The collapse is tonal, not lexical.</b> 92–95% of every story carries
solemn/elegiac vocabulary; only ~15% carries comic vocabulary — even on explicitly comic prompts.
Given “Cthulhu disappoints his constituency by failing to deliver the promised chaos” (a joke),
the base model wrote six straight-faced atmospheric-horror pieces. Consequence: n-gram metrics
(distinct-4, self-BLEU) are near-blind to this failure mode; only embedding-based measures see it.</div>
<h2>2. Main result — E0 vs E1, 300 steps each</h2>
<p>Identical data, seed, learning rate (3e-5), step count and LoRA config. 4,800 stories scored
per arm. First quarter vs last quarter of each run.</p>
{tbl(rows, ["metric", "E0 start", "E0 end", "E0 Δ", "E1 start", "E1 end", "E1 Δ", "E1/E0"], hi={"E1 Δ", "E1/E0"})}
<div class="key"><b>Reading it.</b> Deviation +0.0200 vs +0.0037 (5.4×) and log-det +0.935 vs
+0.165 (5.7×), for −0.06 judge quality. Note also <b>story length</b>: E0 gained +30.8 words —
it discovered “write longer” as a cheap way to please the judge — while E1 gained +0.7. The
diversity term removed that incentive, which also means E1's diversity gain cannot be a length
artifact.</div>
{img(FIGS / "E0_vs_E1_comparison.png")}
<h2>3. Held-out generalization — checkpoint study</h2>
<p>10 held-out prompts × 6 samples at T=0.9, fixed seed, generated from every checkpoint.
960 stories read across both arms.</p>
{tbl(ckrows, ["arm", "step", "quality", "eff_rank (of 6)", "deviation", "logdet", "words"])}
{img(FIGS / "E0-baseline_trajectory.png")}
<h2>4. Training diagnostics</h2>
{img(FIGS / "E0-baseline_diagnostics.png")}
{img(FIGS / "E1-div-individual_diagnostics.png")}
<h2>5. What the stories actually look like</h2>
<p>Opening sentences of all 6 samples per prompt. Base model shown once (identical starting point
for both arms), then each arm at step 300.</p>
{ex_html}
<h3>One complete story — E1 @ step 300</h3>
<div class="story">{full_story}</div>
<h2>6. Held-out evaluation — the definitive result</h2>
<p>480 stories per model: 30 held-out prompts x 16 samples, T=0.9, top_p=0.95, identical seed.
Judge health on this run: 496 calls, 1 failure (0.2%).</p>
{tbl(evrows, ["model","quality","eff_rank (of 16)","pairwise","logdet","ends cleanly","words"], hi={"eff_rank (of 16)"})}
<div class="key"><b>E1 wins on BOTH axes.</b> Against base: effective rank +0.190 vs E0's +0.066
(<b>2.9x</b>), log-det +2.380 vs +0.873 (<b>2.7x</b>), and judge quality +0.392 vs +0.244
(<b>1.6x</b>). This is not a diversity-for-quality trade — E1 is better at both.</div>
{img(FIGS / "eval_frontier.png", "78%")}
<h3>The methodological result: n-gram metrics are blind to this</h3>
<p>The same 480 stories per model, scored two ways:</p>
{tbl(blind, ["model","Δ eff_rank (embed)","Δ logdet (embed)","Δ distinct-4 (n-gram)","Δ self-BLEU (n-gram)","Δ quality"], hi={"Δ eff_rank (embed)"})}
<div class="warn"><b>Embedding metrics separate the arms by 2.9x. N-gram metrics do not separate
them at all</b> — distinct-4 actually rates E0 <i>higher</i> than E1, and self-BLEU is identical to
three decimals. The collapse (and its repair) is tonal and structural, not lexical, so distinct-n
and self-BLEU cannot see it. Evaluating creative diversity with n-gram metrics alone would have
concluded these two models are the same.</div>
{img(FIGS / "eval_metric_blindness.png")}
<h2>7. Qualitative read — what actually changed in the writing</h2>
<p>10 held-out prompts x 6 samples from every checkpoint of both arms, at two sampling settings.
Stories read in full for three prompts; openings and premises scanned for all ten.</p>
<div class="key"><b>The measurement that summarises the read.</b> Fraction of step-300 samples whose
first 8 words verbatim-reuse one of the <i>base model's</i> openings for that prompt:
<b>E0 31.7% (19/60) vs E1 11.7% (7/60)</b> — 2.7x less template reuse, tracking the 2.9x
effective-rank separation almost exactly.</div>
<h3>E0 keeps the base model's frame and polishes the inside</h3>
<p>E0's step-300 openings are frequently near-verbatim to base:</p>
{tbl(qrows, ["prompt","base","E0 @ 300"])}
<p>The improvement is real but <i>internal</i>. E0's bodies are richer and better organised — one
graduation sample develops an explicit “Year One / Year Two / Year Three” structure the base never
attempts, with far more specific detail (“Jenna's red scarf”, “Jake's habit of drawing tiny suns on
his H.W. papers”). That is exactly what a per-story quality judge rewards, and why E0's judge score
rises +0.24 while its diversity does not move. <b>E0 is a better writer telling the same story.</b></p>
<h3>E1 changes the entry point, the premise and the point of view</h3>
<p><b>Graduation prompt.</b> Base and E0 open <i>at the podium</i>, in the ceremony, in every
sample. Two of E1's three open in retrospection instead — no stage, no lights, no crowd:</p>
<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.
I've never raised my hand in class. Not once.</div>
<p><b>Martial-arts prompt.</b> Base and E0 write the student as a humble supplicant (“I… I just
want to learn”; “No app on her wrist. No headset. Just folded hands”). E1 rewrites the
relationship into a confrontation:</p>
<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>
<p>Another E1 sample relocates the scene from dojo to neon city street; a third inverts the premise
entirely — the student says <i>“I didn't download anything. I just… felt it.”</i></p>
<p><b>“The ash turned to snow.”</b> Base and E0 use one template in all six samples: a named lone
adult, at a rural dwelling, remembering (Magda/cottage, Elena/clearing, Masahiro/temple;
Marlow/garden, Eli/watchtower, Lyra/cottage). E1 breaks both scale and POV:</p>
<div class="story">Children appeared where none had been. Not from the rubble, not from the forgotten alleyways — just there.
The children didn't know the word *smoke*. They didn't need to.</div>
<p><b>Black Friday prompt</b> — the clearest case. Base uses “The air in the X Mall…” or “The sky
burned crimson…” in all four sampled openings; E0 preserves it (“The air in the Glendale Mall…”,
“The air in the Orchard Mall…”). E1 uses none of it: <i>“No one remembers the date. The clocks
stopped on a Tuesday.”</i> / <i>“The temperature dropped the second the lights went out.”</i></p>
<h3>The ceiling: neither arm broke the tonal monoculture</h3>
{tbl(formrows, ["form","base","E0 @300","E1 @300","E1-E0"], hi={"E1 @300"})}
<div class="warn"><b>E1 invented second-person narration</b> — base uses it on 0/10 prompts, E1 on
5/10. Present tense doubled. <b>E0 loses forms</b> (1.70 → 1.50): quality-only training narrows the
repertoire. But <b>solemn/elegiac is 1.00 in every condition</b>. Every story in this study, from
every checkpoint of every arm, is written in the same melancholy literary register. E1 diversifies
grammatical person, tense, scale, POV and premise — it does <i>not</i> diversify tone.
<b>But that table undercounts E1.</b> Reading the Cthulhu stories in full (a prompt whose entire
premise is a joke), the base model and E0 write it straight — E0's opening is verbatim base. E1
produces genuine absurdist invention the base never approaches: <i>“Cthulhu awoke not in the deep,
sulfurous dark, but on a balcony in Manhattan… His tentacle reached out and touched the radio tower.
It simply began playing Chopin's Nocturne in E-flat at full volume… Cthulhu sat on a park bench,
observing a dog chase a red ball.”</i> and <i>“a concert in Helsinki where a hundred thousand people
played accordions in perfect unison, each note tuned to a specific frequency of sea bass in the
Barents Sea.”</i> The keyword-based register detector scored these as non-comic because the humour is
<b>situational, not lexical</b>. The monoculture ceiling is real but softer than the table implies.</div>
<h3>E1 answers the prompt's question; base and E0 describe around it</h3>
<p>The NYC prompt asks <b>“Why?”</b> — it demands a mechanism. Base and E0 mostly supply atmospheric
vignettes with no explanation (“No one knew why. No one asked.”). One E1 sample instead writes a
dialogue-driven science-fiction scene that actually answers it — the only sample across all three
conditions to supply a causal mechanism:</p>
<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>
<p>Another E1 sample writes in the <b>present tense</b> and refuses the consoling ending — the
violence returns at midnight (“A man in a grocery bag gets his arm slashed by a scrawny boy,
screaming”), where base and E0 both resolve into calm.</p>
<div class="key"><b>Conclusion of the read: E0 is a better writer telling the same story; E1 tells
different stories.</b> That distinction is invisible to per-story quality scoring (both arms
improve), invisible to n-gram metrics (distinct-4 rates E0 <i>higher</i>), and visible to
embedding-based measures — the methodological argument of this project, arrived at independently by
reading. What E1 has <i>not</i> achieved is tonal range: the next objective to target is register
explicitly.</div>
<h2>8. Honest limitations</h2>
<div class="warn">
<p><b>E1 does not fix verbatim opening duplication.</b> At step 300, unique-opening rate is 0.883
for E1 vs 0.900 for E0 — marginally <i>worse</i> — and both arms have 1/10 prompts with ≥3
identical openings. What E1 gains is <b>register spread</b> (2.00→2.40 distinct forms, while E0
falls 2.00→1.40). The diversity reward broadens <i>what kind of thing</i> the model writes without
fixing <i>how it starts sentences</i>.</p>
<p><b>Whole-story embeddings can miss positional collapse.</b> In E0 one prompt went from 6
distinct openings to 5-of-6 identical while effective rank and deviation both drifted <i>up</i>.
Unique-opening rate should be a first-class metric, not a diagnostic afterthought.</p>
<p><b>Effect sizes are modest in absolute terms</b> — E1's held-out effective rank is 1.80 against
a ceiling of 6. The floor was lifted, not escaped.</p>
<p><b>The effect needed ~150 steps to emerge from noise.</b> At batch 88 E1 was statistically
indistinguishable from E0. A 100-step study would have concluded diversity rewards do not work.</p>
<p><b>Entropy did not separate the arms.</b> Both fell (E0 −2.1%, E1 −3.1%). An earlier mid-run
window suggested E1's entropy was rising; that did not survive the full run. Token entropy and
semantic diversity are dissociated — which is the point, but not in the direction first reported.</p>
</div>
<h2>9. Recommendations</h2>
<ol>
<li><b>Set β (KL) to 0.</b> Measured at 0.4% of loss magnitude — already near-inert. The
principled argument is stronger: the reference model <i>is</i> the collapsed distribution
(eff. rank 2.0/16), so KL regularizes <i>toward</i> the pathology under study. Programmatic gates
do KL's usual job without that conflict.</li>
<li><b>Raise α from 0.5 to 1.0–2.0.</b> Quality and diversity are nearly independent across
prompts (r = −0.108), so there is slack to spend, and E1 paid almost nothing for its gain.</li>
<li><b>Add unique-opening-rate to the reward</b>, not just to eval — it catches what log-det misses.</li>
<li><b>Train longer.</b> Both arms were still moving at 300 steps.</li>
<li><b>Learning rate matters more than anything else here.</b> At the brief's 3e-6 (a full-FT
rate applied to LoRA adapters) the policy was frozen: KL pinned at 0.0008 for 171 steps, every
metric inside its noise band. 3e-5 was required to make <i>any</i> arm measurable.</li>
</ol>
</body></html>"""
out_html = ROOT / "report.html"
out_html.write_text(html)
from weasyprint import HTML
pdf = ROOT / "REPORT.pdf"
HTML(string=html, base_url=str(ROOT)).write_pdf(str(pdf))
print("wrote", pdf, f"({pdf.stat().st_size/1e6:.1f} MB)")
print("wrote", out_html)
if __name__ == "__main__":
sys.exit(main())