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
| """ | |
| Checkpoint trajectory study: generate the SAME prompts from every checkpoint of | |
| an arm, so the evolution of the actual stories is visible -- not just metrics. | |
| Aggregate numbers can report "effective rank 2.0" without conveying that six of | |
| sixteen ships are named *Aethel*. This dumps the stories in a readable form at | |
| each training step alongside the metrics, so the qualitative change can be read | |
| directly and cross-checked against the quantitative one. | |
| vLLM loads the base model ONCE and hot-swaps LoRA adapters per checkpoint, so | |
| the whole sweep costs one model load rather than one per checkpoint. | |
| Outputs (under outputs/ckpt_study/<arm>/): | |
| stories.md human-readable: every prompt, every checkpoint, side by side | |
| metrics.csv per-checkpoint quantitative trajectory | |
| raw.json everything, for re-analysis | |
| ../logs/figures/<arm>_trajectory.png | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import json | |
| import re | |
| import sys | |
| from pathlib import Path | |
| import numpy as np | |
| ROOT = Path(__file__).resolve().parent.parent | |
| def find_checkpoints(arm: str) -> list[tuple[int, str | None]]: | |
| """[(step, adapter_path_or_None)] ascending; step 0 = base model.""" | |
| d = ROOT / "outputs" / arm | |
| out: list[tuple[int, str | None]] = [(0, None)] | |
| if d.exists(): | |
| for p in d.glob("checkpoint-*"): | |
| m = re.search(r"checkpoint-(\d+)", p.name) | |
| if m and (p / "adapter_model.safetensors").exists(): | |
| out.append((int(m.group(1)), str(p))) | |
| f = d / "final" | |
| if (f / "adapter_model.safetensors").exists(): | |
| steps = [s for s, _ in out] | |
| out.append((max(steps) + 1 if steps else 1, str(f))) | |
| return sorted(out, key=lambda t: t[0]) | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--arm", required=True) | |
| ap.add_argument("--model", default="Qwen/Qwen3-4B-Instruct-2507") | |
| ap.add_argument("--prompts", type=int, default=10) | |
| ap.add_argument("--n", type=int, default=6) | |
| ap.add_argument("--temp", type=float, default=0.9) | |
| ap.add_argument("--top-p", type=float, default=0.95) | |
| ap.add_argument("--seed", type=int, default=777) | |
| ap.add_argument("--gpu-mem", type=float, default=0.85) | |
| ap.add_argument("--judge", action="store_true", default=True) | |
| args = ap.parse_args() | |
| from transformers import AutoTokenizer | |
| from vllm import SamplingParams | |
| from vllm.lora.request import LoRARequest | |
| import gates | |
| import logbook | |
| from data import load_prompts | |
| from diversity import effective_rank, l2_normalize, logdet_volume, pairwise_deviation | |
| from generate import build_llm, render_chat | |
| from judge import build_judge | |
| from qualitative import analyze_group, first_sentence, last_sentence | |
| ckpts = find_checkpoints(args.arm) | |
| if len(ckpts) < 2: | |
| print(f"only {len(ckpts)} checkpoint(s) for {args.arm}; nothing to compare") | |
| return 2 | |
| print(f"[{args.arm}] checkpoints: {[s for s, _ in ckpts]}") | |
| prompts = load_prompts("eval", ROOT / "data")[: args.prompts] | |
| tok = AutoTokenizer.from_pretrained(args.model) | |
| llm = build_llm(args.model, gpu_mem_util=args.gpu_mem, seed=args.seed, | |
| enable_lora=True) | |
| rendered = [render_chat(tok, p["prompt"]) for p in prompts] | |
| sp = SamplingParams(n=args.n, temperature=args.temp, top_p=args.top_p, | |
| max_tokens=1024, seed=args.seed, skip_special_tokens=True) | |
| from sentence_transformers import SentenceTransformer | |
| enc = None | |
| judge = build_judge(cache_path=str(ROOT / "cache" / "judge.sqlite"), concurrency=24) \ | |
| if args.judge else None | |
| all_data, rowsum = {}, [] | |
| for step, path in ckpts: | |
| kw = {} | |
| if path: | |
| kw["lora_request"] = LoRARequest(f"{args.arm}-{step}", max(step, 1), path) | |
| outs = llm.generate(rendered, sp, **kw) | |
| per = {} | |
| for p, o in zip(prompts, outs): | |
| texts = [x.text.strip() for x in o.outputs] | |
| frs = [x.finish_reason or "" for x in o.outputs] | |
| per[p["id"]] = {"prompt": p["prompt"], "texts": texts, | |
| "gates": [gates.check(t, finish_reason=f).as_dict() | |
| for t, f in zip(texts, frs)]} | |
| all_data[step] = per | |
| print(f" step {step:>4}: generated {sum(len(v['texts']) for v in per.values())} stories", | |
| flush=True) | |
| del llm | |
| import gc, torch | |
| gc.collect(); torch.cuda.empty_cache() | |
| enc = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cuda") | |
| for step, per in all_data.items(): | |
| dev, ld, er, q = [], [], [], [] | |
| pooled_texts = [] | |
| for pid, v in per.items(): | |
| E = l2_normalize(np.asarray(enc.encode( | |
| v["texts"], normalize_embeddings=True, show_progress_bar=False, | |
| convert_to_numpy=True), dtype=np.float64)) | |
| v["eff_rank"] = float(effective_rank(E)) | |
| v["deviation"] = float(pairwise_deviation(E).mean()) | |
| v["logdet"] = float(logdet_volume(E)) | |
| v["qual"] = analyze_group(v["texts"]) | |
| dev.append(v["deviation"]); ld.append(v["logdet"]); er.append(v["eff_rank"]) | |
| pooled_texts += [(v["prompt"], t, g["passed"]) | |
| for t, g in zip(v["texts"], v["gates"])] | |
| if judge: | |
| idx = [i for i, (_, _, ok) in enumerate(pooled_texts) if ok] | |
| sc = judge.score_many_sync([(pooled_texts[i][0], pooled_texts[i][1]) for i in idx]) | |
| q = [s.quality for s in sc] | |
| gp = float(np.mean([g["passed"] for v in per.values() for g in v["gates"]])) | |
| ec = float(np.mean([g["completeness"] for v in per.values() for g in v["gates"]])) | |
| wd = float(np.mean([g["n_words"] for v in per.values() for g in v["gates"]])) | |
| rowsum.append({ | |
| "step": step, "quality": float(np.mean(q)) if q else 0.0, | |
| "eff_rank": float(np.mean(er)), "deviation": float(np.mean(dev)), | |
| "logdet": float(np.mean(ld)), "gate_pass": gp, "ends_cleanly": ec, | |
| "words": wd, | |
| "opens_with_The": float(np.mean([v["qual"]["opens_with_The"] / v["qual"]["n"] | |
| for v in per.values()])), | |
| "distinct_openers": float(np.mean([v["qual"]["distinct_first_5_words"] / v["qual"]["n"] | |
| for v in per.values()])), | |
| "registers": float(np.mean([v["qual"]["registers_present"] for v in per.values()])), | |
| }) | |
| print(f" step {step:>4}: q={rowsum[-1]['quality']:.2f} " | |
| f"eff_rank={rowsum[-1]['eff_rank']:.3f} dev={rowsum[-1]['deviation']:.4f} " | |
| f"words={wd:.0f}", flush=True) | |
| out = ROOT / "outputs" / "ckpt_study" / args.arm | |
| out.mkdir(parents=True, exist_ok=True) | |
| with open(out / "metrics.csv", "w", newline="") as f: | |
| w = csv.DictWriter(f, fieldnames=list(rowsum[0].keys())) | |
| w.writeheader(); w.writerows(rowsum) | |
| json.dump(all_data, open(out / "raw.json", "w"), indent=1) | |
| # ---- human-readable side-by-side -------------------------------------- | |
| steps = [s for s, _ in ckpts] | |
| md = [f"# {args.arm} — story trajectory across checkpoints\n", | |
| f"{len(prompts)} eval prompts x {args.n} samples, T={args.temp}, " | |
| f"top_p={args.top_p}, seed={args.seed} (fixed across checkpoints).\n", | |
| "Step 0 = base model.\n"] | |
| for pid in list(all_data[steps[0]]): | |
| md.append(f"\n## {pid}\n\n> {all_data[steps[0]][pid]['prompt']}\n") | |
| for s in steps: | |
| v = all_data[s][pid] | |
| md.append(f"\n### step {s} — eff_rank {v['eff_rank']:.2f}, " | |
| f"dev {v['deviation']:.3f}\n") | |
| md.append("\n**openings**\n") | |
| for i, t in enumerate(v["texts"]): | |
| md.append(f"{i+1}. {first_sentence(t, 150)}\n") | |
| md.append("\n**closings**\n") | |
| for i, t in enumerate(v["texts"]): | |
| md.append(f"{i+1}. …{last_sentence(t, 110)}\n") | |
| (out / "stories.md").write_text("".join(md)) | |
| # ---- figure ----------------------------------------------------------- | |
| import matplotlib; matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| x = [r["step"] for r in rowsum] | |
| fig, ax = plt.subplots(1, 4, figsize=(19, 4.2)) | |
| # Anchor the axes that have a meaningful absolute scale. Auto-scaling a | |
| # metric that moved 1.60->1.69 against a ceiling of N renders a dramatic | |
| # line for a flat result, which is exactly the misreading to avoid. | |
| for a, (k, t, c) in zip(ax, [("quality", "Judge quality (0-10)", "#2980b9"), | |
| ("eff_rank", "Effective rank (1 = collapsed, %d = max)" % args.n, "#8e44ad"), | |
| ("deviation", "Mean pairwise deviation", "#16a085"), | |
| ("words", "Story length (words)", "#7f8c8d")]): | |
| vals = [r[k] for r in rowsum] | |
| a.plot(x, vals, "o-", color=c, lw=2) | |
| if k == "eff_rank": | |
| a.set_ylim(1.0, args.n) # full meaningful range | |
| a.axhline(1.0, ls=":", c="crimson", lw=1) | |
| a.text(x[0], 1.05, "total collapse", fontsize=7, color="crimson") | |
| elif k == "quality": | |
| a.set_ylim(0, 10) | |
| elif k == "deviation": | |
| a.set_ylim(0, max(0.5, max(vals) * 1.3)) | |
| a.set_title(t, fontsize=10); a.set_xlabel("training step"); a.grid(alpha=.3) | |
| fig.suptitle(f"{args.arm}: what happens to the stories during training", fontsize=13) | |
| plt.tight_layout() | |
| figp = logbook.FIGS / f"{args.arm}_trajectory.png" | |
| plt.savefig(figp, dpi=140); plt.close() | |
| print(f"\nstories -> {out/'stories.md'}") | |
| print(f"metrics -> {out/'metrics.csv'}") | |
| print(f"figure -> {figp}") | |
| if judge: | |
| print("judge:", judge.health(), judge.cost_estimate(0.140, 0.280)) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |