creative-writing-llm / src /qualitative.py
Pranav2748's picture
Add src
cbc33fe verified
Raw
History Blame Contribute Delete
6.42 kB
"""
Qualitative diff across arms: actually READ the stories.
Aggregate metrics can say "effective rank 2.0/16" without conveying that six of
the sixteen ships are literally named *Aethel*. This module extracts the
concrete signature of collapse -- shared openings, repeated proper nouns,
shared closing cadence, tone/genre spread -- so a human can see what changed.
Works on either a scored pool (`--pool`) or an eval dump (`--eval`), so the
base model, every checkpoint and every arm are read the same way.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parent.parent
OPEN_FRAME = re.compile(r"^The\s+\*?([A-Z][\w' ]{2,20})\*?\s+(\w+ed|\w+s)\s+"
r"(through|over|above|at|into|across)\b")
ITALIC_NAME = re.compile(r"\*([A-Z][\w' ]{2,24})\*")
CADENCE = [
(r"for the first time in (centuries|years|generations|decades|a long time)",
"'for the first time in X'"),
(r"\b(had )?begun to\b", "'begun to'"),
(r"\bwasn'?t\s+\w+[^.]{0,50},?\s+it\s+was\b", "'it wasn't X, it was Y'"),
(r"\n\n[^\n]{1,60}\.\s*$", "one-line closing paragraph"),
]
# crude genre/register probes -- presence of ANY is weak, but the SPREAD across
# a 16-sample set is the interesting number
REGISTER = {
"elegiac/literary": r"\b(silence|ache|hollow|dust|memory|grief|quiet)\b",
"comic": r"\b(ridiculous|absurd|joke|laughed|idiot|bureaucra|paperwork)\b",
"horror": r"\b(scream|blood|rot|teeth|corpse|terror|wrong)\b",
"technical/SF": r"\b(protocol|sensor|reactor|alloy|deploy|calibrat|drone)\b",
"dialogue-driven": r'"[^"]{10,}"',
"second person": r"\bYou (are|were|walk|feel|stand|know)\b",
"epistolary": r"\b(Dear |entry |log:|Report |memo)\b",
}
def first_sentence(t: str, n: int = 130) -> str:
return re.split(r"(?<=[.!?])\s", t.strip())[0][:n]
def last_sentence(t: str, n: int = 120) -> str:
return re.split(r"(?<=[.!?])\s", t.strip())[-1][:n]
def analyze_group(texts: list[str]) -> dict:
n = len(texts)
names = Counter(x for t in texts for x in ITALIC_NAME.findall(t))
opens_the = sum(1 for t in texts if t.strip().startswith("The "))
frame = sum(1 for t in texts if OPEN_FRAME.match(t.strip()))
# repeated content words across stories (excluding prompt-driven ones is
# impossible in general, so we report the top shared nouns as-is)
stop = set("the a an and or but of to in on it its was were is are that this "
"with for as at by from had have has been be he she they we you i "
"his her their our my not no so then than there here what when "
"which who all more most into over under out up down".split())
per_story_vocab = [set(w for w in re.sub(r"[^a-z\s]", " ", t.lower()).split()
if w not in stop and len(w) > 3) for t in texts]
shared = Counter(w for v in per_story_vocab for w in v)
ubiquitous = {w: c for w, c in shared.most_common(400) if c >= max(3, int(0.6 * n))}
cad = {}
for pat, label in CADENCE:
cad[label] = sum(1 for t in texts if re.search(pat, t, re.I | re.M))
reg = {}
for label, pat in REGISTER.items():
reg[label] = sum(1 for t in texts if re.search(pat, t, re.I))
return {
"n": n,
"opens_with_The": opens_the,
"opening_frame_match": frame,
"distinct_first_5_words": len({" ".join(t.strip().split()[:5]).lower() for t in texts}),
"top_names": dict(names.most_common(6)),
"max_name_reuse": max(names.values()) if names else 0,
"ubiquitous_words": dict(list(ubiquitous.items())[:12]),
"cadence": cad,
"register_spread": reg,
"registers_present": sum(1 for v in reg.values() if v >= max(2, int(0.15 * n))),
}
def load_pool(tag: str, split: str, prompt_id: str | None):
rows = [json.loads(l) for l in
open(ROOT / "outputs" / f"pool_{tag}" / f"pool_{split}.jsonl") if l.strip()]
by = defaultdict(list)
for r in rows:
by[r["prompt_id"]].append(r)
if prompt_id:
return {prompt_id: by[prompt_id]}
return dict(by)
def load_eval(label: str, out: str):
p = ROOT / out / "samples" / f"{label}_full.json"
data = json.load(open(p))
return {d["prompt_id"]: [{"text": t, "prompt": d["prompt"]} for t in d["texts"]]
for d in data}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--pool", help="pool tag, e.g. 4b")
ap.add_argument("--eval", help="eval label, e.g. E0-baseline")
ap.add_argument("--out", default="outputs/eval")
ap.add_argument("--split", default="train")
ap.add_argument("--prompt-id", default=None)
ap.add_argument("--show", type=int, default=16, help="first sentences to print")
ap.add_argument("--limit-prompts", type=int, default=40)
args = ap.parse_args()
groups = load_pool(args.pool, args.split, args.prompt_id) if args.pool \
else load_eval(args.eval, args.out)
keys = list(groups)[: args.limit_prompts]
agg = defaultdict(list)
for pid in keys:
texts = [r["text"] for r in groups[pid]]
if len(texts) < 4:
continue
a = analyze_group(texts)
agg["opens_with_The"].append(a["opens_with_The"] / a["n"])
agg["opening_frame"].append(a["opening_frame_match"] / a["n"])
agg["distinct_openers"].append(a["distinct_first_5_words"] / a["n"])
agg["max_name_reuse"].append(a["max_name_reuse"])
agg["registers_present"].append(a["registers_present"])
for k, v in a["cadence"].items():
agg[f"cadence:{k}"].append(v / a["n"])
name = args.eval or f"pool-{args.pool}"
print(f"\n===== QUALITATIVE PROFILE: {name} ({len(keys)} prompts) =====")
for k, v in agg.items():
print(f" {k:38} {np.mean(v):.3f}")
pid = args.prompt_id or keys[0]
texts = [r["text"] for r in groups[pid]][: args.show]
print(f"\n--- prompt {pid} ---")
print(" ", groups[pid][0].get("prompt", "")[:220])
print("\nOPENINGS:")
for i, t in enumerate(texts):
print(f" {i+1:2d}. {first_sentence(t)}")
print("\nCLOSINGS:")
for i, t in enumerate(texts):
print(f" {i+1:2d}. ...{last_sentence(t)}")
return 0
if __name__ == "__main__":
sys.exit(main())