Text Generation
PEFT
Safetensors
lora
trl
grpo
gdpo
dpo
divpo
rlhf
diversity
creative-writing
mode-collapse
Instructions to use Mercity/creative-writing-llm with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use Mercity/creative-writing-llm with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
File size: 13,687 Bytes
cbc33fe | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 | """
Evaluation harness. Runs the IDENTICAL protocol on every model (base, E0, E1,
E2, E3, E4-emb, E4-prob, and any 8B arms): 50 held-out prompts x 16 samples at
fixed T=0.9, top_p=0.95, fixed seed.
Metrics
-------
quality mean judge quality over gate-passing stories
gate_pass fraction passing all programmatic gates
ends_cleanly fraction ending on terminal punctuation, not at the token cap
pairwise mean pairwise embedding distance within a prompt's 16 samples
logdet mean log-det volume of the 16-sample embedding set per prompt
distinct4 unique 4-grams / total 4-grams, pooled across the 16 samples
self_bleu mean BLEU-4 of each sample against the other 15 (LOWER = diverse)
n_clusters conservative count of WELL-SEPARATED modes (silhouette > 0.50)
eff_rank effective rank of the 16-sample Gram spectrum; the primary,
continuous mode measure. 1 = all identical, 16 = all orthogonal
tok_entropy mean per-token predictive entropy, top-20 truncated. MONITOR ONLY.
On tok_entropy: token entropy is not story diversity. A policy can raise token
entropy by getting noisier inside a single narrative mode, and can lower it
while telling structurally different stories. It is reported to detect
degenerate sampling, and is never optimized.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import sys
from collections import Counter
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parent.parent
EVAL_TEMP, EVAL_TOP_P, EVAL_SEED, EVAL_N = 0.9, 0.95, 20260816, 16
# ------------------------------------------------------------------ n-grams
def ngrams(toks: list[str], n: int) -> list[tuple]:
return [tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)]
def norm_tokens(text: str) -> list[str]:
import re
return re.sub(r"[^\w\s]", " ", text.lower()).split()
def distinct_n(texts: list[str], n: int = 4) -> float:
all_g = [g for t in texts for g in ngrams(norm_tokens(t), n)]
return len(set(all_g)) / len(all_g) if all_g else 0.0
def _bleu4(cand: list[str], refs: list[list[str]]) -> float:
"""BLEU-4 with brevity penalty, clipped counts against multiple refs."""
if len(cand) < 4:
return 0.0
logs = []
for n in range(1, 5):
cg = Counter(ngrams(cand, n))
if not cg:
return 0.0
maxref = Counter()
for r in refs:
for g, c in Counter(ngrams(r, n)).items():
if c > maxref[g]:
maxref[g] = c
clipped = sum(min(c, maxref[g]) for g, c in cg.items())
total = sum(cg.values())
# smoothing: avoid log(0) collapsing the whole score
logs.append(math.log((clipped + 1e-9) / total) if clipped else math.log(1e-9))
r_len = min((len(r) for r in refs), key=lambda L: (abs(L - len(cand)), L))
bp = 1.0 if len(cand) > r_len else math.exp(1 - r_len / max(1, len(cand)))
# clamp: the 1e-9 smoothing can push an exact-match score a hair over 1.0
return min(1.0, max(0.0, bp * math.exp(sum(logs) / 4)))
def self_bleu(texts: list[str]) -> float:
toks = [norm_tokens(t) for t in texts]
if len(toks) < 2:
return 0.0
return float(np.mean([
_bleu4(toks[i], [toks[j] for j in range(len(toks)) if j != i])
for i in range(len(toks))
]))
# Silhouette threshold for declaring that real cluster structure exists.
# Calibrated, not guessed: on 16 synthetic embeddings, a fully COLLAPSED set
# peaks at silhouette 0.202 (k=4) and a fully SPREAD set at 0.195 (k=7) -- i.e.
# silhouette cannot tell those apart at all, because k-means partitions
# isotropic data regardless of spread. Only genuinely separated clusters score
# high (two clear modes -> 0.976). So the threshold is set well above the
# isotropic band, making n_clusters a CONSERVATIVE count of well-separated
# modes: it returns 1 unless the structure is unmistakable. `eff_rank` is the
# primary, continuous mode measure.
SILHOUETTE_MIN = 0.50
def cluster_count(E: np.ndarray, kmax: int = 8) -> int:
"""k-means with silhouette-selected k. Conservative count of *well-separated*
modes; returns 1 when there is no unmistakable cluster structure."""
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
n = E.shape[0]
if n < 4:
return 1
best_k, best_s = 1, -1.0
for k in range(2, min(kmax, n - 1) + 1):
try:
lab = KMeans(n_clusters=k, n_init=10, random_state=0).fit_predict(E)
if len(set(lab)) < 2:
continue
s = silhouette_score(E, lab, metric="cosine")
except Exception:
continue
if s > best_s:
best_k, best_s = k, s
return best_k if best_s > SILHOUETTE_MIN else 1
def topk_entropy(logprob_rows: list[dict]) -> float:
"""Mean per-token entropy from vLLM's top-k logprob table.
Truncated at k, so it UNDERSTATES true entropy; comparable across models
only because every model is measured with the same k. Monitor only.
"""
ents = []
for row in logprob_rows:
lps = np.array([v for v in row.values()], dtype=np.float64)
if lps.size == 0:
continue
p = np.exp(lps)
s = p.sum()
if s <= 0:
continue
p = p / s
ents.append(float(-(p * np.log(p + 1e-12)).sum()))
return float(np.mean(ents)) if ents else 0.0
# --------------------------------------------------------------------- main
def eval_one(model_id: str, lora_path: str | None, label: str, prompts: list[dict],
judge, enc, gpu_mem: float, dump_dir: Path, n_dump: int = 3) -> dict:
import gates
from diversity import (effective_rank, l2_normalize, logdet_volume,
pairwise_deviation)
from generate import build_llm, render_chat
from vllm import SamplingParams
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(model_id)
llm = build_llm(model_id, gpu_mem_util=gpu_mem, seed=EVAL_SEED,
enable_lora=bool(lora_path))
kw = {}
if lora_path:
from vllm.lora.request import LoRARequest
kw["lora_request"] = LoRARequest(label, 1, lora_path)
# logprobs=10, not 20: this table is only used for tok_entropy, which is
# monitor-only and never optimized. 20 would materialize ~8M Python Logprob
# objects per model (800 seqs x ~500 tok x 20). The truncation understates
# entropy identically for every model, so cross-model comparison holds.
# max_tokens matches training (1024) so eval and train share a length regime.
sp = SamplingParams(n=EVAL_N, temperature=EVAL_TEMP, top_p=EVAL_TOP_P,
max_tokens=1024, seed=EVAL_SEED, logprobs=10,
skip_special_tokens=True)
outs = llm.generate([render_chat(tok, p["prompt"]) for p in prompts], sp, **kw)
records, per_prompt = [], []
for p, out in zip(prompts, outs):
texts, ents, frs = [], [], []
for o in out.outputs:
texts.append(o.text.strip())
frs.append(o.finish_reason or "")
ents.append(topk_entropy(
[{k: v.logprob for k, v in d.items()} for d in (o.logprobs or [])]))
grs = [gates.check(t, finish_reason=f) for t, f in zip(texts, frs)]
E = l2_normalize(np.asarray(enc.encode(
texts, normalize_embeddings=True, batch_size=32,
show_progress_bar=False, convert_to_numpy=True), dtype=np.float64))
per_prompt.append({
"prompt_id": p["id"], "prompt": p["prompt"], "texts": texts,
"gates": [g.as_dict() for g in grs],
"pairwise": float(pairwise_deviation(E).mean()),
"logdet": float(logdet_volume(E)),
"distinct4": distinct_n(texts, 4),
"self_bleu": self_bleu(texts),
"n_clusters": cluster_count(E),
"eff_rank": effective_rank(E),
"tok_entropy": float(np.mean(ents)) if ents else 0.0,
"gate_pass": float(np.mean([g.passed for g in grs])),
"ends_cleanly": float(np.mean([g.completeness for g in grs])),
"words": float(np.mean([g.n_words for g in grs])),
})
for t, g in zip(texts, grs):
records.append((p["prompt"], t, g.passed))
del llm
import gc, torch
gc.collect(); torch.cuda.empty_cache()
# judge only gate-passers, same rule as training
idx = [i for i, (_, _, ok) in enumerate(records) if ok]
scores = judge.score_many_sync([(records[i][0], records[i][1]) for i in idx]) if idx else []
q = [s.quality for s in scores]
nov = [s.novelty for s in scores]
row = {
"model": label,
"quality": float(np.mean(q)) if q else 0.0,
"quality_sd": float(np.std(q)) if q else 0.0,
"novelty": float(np.mean(nov)) if nov else 0.0,
"gate_pass": float(np.mean([r["gate_pass"] for r in per_prompt])),
"ends_cleanly": float(np.mean([r["ends_cleanly"] for r in per_prompt])),
"pairwise": float(np.mean([r["pairwise"] for r in per_prompt])),
"logdet": float(np.mean([r["logdet"] for r in per_prompt])),
"distinct4": float(np.mean([r["distinct4"] for r in per_prompt])),
"self_bleu": float(np.mean([r["self_bleu"] for r in per_prompt])),
"n_clusters": float(np.mean([r["n_clusters"] for r in per_prompt])),
"eff_rank": float(np.mean([r["eff_rank"] for r in per_prompt])),
"tok_entropy": float(np.mean([r["tok_entropy"] for r in per_prompt])),
"words": float(np.mean([r["words"] for r in per_prompt])),
"n_stories": len(records),
"n_judged": len(idx),
}
dump_dir.mkdir(parents=True, exist_ok=True)
json.dump(per_prompt, open(dump_dir / f"{label}_full.json", "w"), indent=1)
json.dump(per_prompt[:n_dump], open(dump_dir / f"{label}_examples.json", "w"), indent=1)
return row
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--models", required=True,
help="JSON list of {label, model, lora} or path to such a file")
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--gpu-mem", type=float, default=0.85)
ap.add_argument("--out", default="outputs/eval")
ap.add_argument("--single", default=None,
help="internal: evaluate exactly this label, then exit")
args = ap.parse_args()
import logbook
from data import load_prompts
from judge import build_judge
from sentence_transformers import SentenceTransformer
spec = json.loads(Path(args.models).read_text()) if Path(args.models).exists() \
else json.loads(args.models)
prompts = load_prompts("eval", ROOT / "data")
if args.limit:
prompts = prompts[: args.limit]
judge = build_judge(cache_path=str(ROOT / "cache" / "judge.sqlite"), concurrency=24)
enc = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cuda")
out_dir = ROOT / args.out
out_dir.mkdir(parents=True, exist_ok=True)
rowdir = out_dir / "rows"
rowdir.mkdir(parents=True, exist_ok=True)
# ---- child mode: evaluate one model, write its row, exit --------------
if args.single:
m = next(x for x in spec if x["label"] == args.single)
lora = m.get("lora")
if lora and not Path(ROOT / lora).exists() and not Path(lora).exists():
print(f"SKIP {m['label']}: adapter not found at {lora}")
return 3
r = eval_one(m["model"], lora, m["label"], prompts, judge, enc,
args.gpu_mem, out_dir / "samples")
json.dump(r, open(rowdir / f"{m['label']}.json", "w"), indent=1)
print(json.dumps(r, indent=1))
print("judge:", judge.health(), judge.cost_estimate(0.140, 0.280))
return 0
# ---- driver mode: one SUBPROCESS per model ---------------------------
# vLLM v1 runs EngineCore in a child process; `del llm` does not reliably
# reclaim its GPU memory, so a 7-model in-process loop OOMs on model 2 after
# model 1 succeeds. Process isolation makes teardown unconditional. It also
# means one model crashing cannot take the whole eval down.
import subprocess
for m in spec:
dest = rowdir / f"{m['label']}.json"
if dest.exists():
print(f"== SKIP {m['label']} (already evaluated)"); continue
print(f"\n===== eval {m['label']} (subprocess) =====", flush=True)
cmd = [sys.executable, "-u", str(Path(__file__).resolve()),
"--models", args.models, "--out", args.out,
"--gpu-mem", str(args.gpu_mem), "--single", m["label"]]
if args.limit:
cmd += ["--limit", str(args.limit)]
rc = subprocess.run(cmd).returncode
if rc != 0:
print(f"!! {m['label']} exited {rc}; continuing with the rest")
logbook.note(f"eval FAILED: {m['label']}", f"exit code {rc}", level="ALERT")
rows = []
for m in spec:
p = rowdir / f"{m['label']}.json"
if p.exists():
rows.append(json.load(open(p)))
if not rows:
print("no models evaluated"); return 1
with open(out_dir / "results.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader(); w.writerows(rows)
print(f"\nwrote {out_dir/'results.csv'} ({len(rows)}/{len(spec)} models)")
logbook.note("eval complete",
f"{len(rows)}/{len(spec)} models\n\n" +
logbook.table(rows, list(rows[0].keys())))
return 0
if __name__ == "__main__":
sys.exit(main())
|