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 prior_run/train.py (API key redacted)
Browse files- prior_run/train.py +503 -0
prior_run/train.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NOTE: this is a copy of prior_run/train.py with a hard-coded OpenRouter API key
|
| 2 |
+
# redacted before publication. The original file is unmodified on disk.
|
| 3 |
+
"""
|
| 4 |
+
GRPO + Diversity via LLM Judge β v2 (GDPO multi-reward)
|
| 5 |
+
--------------------------------------------------------
|
| 6 |
+
Three separate reward functions, decoupled-normalized then summed:
|
| 7 |
+
1. quality_reward = quality_i + 0.5 * group_diversity
|
| 8 |
+
2. novelty_reward = novelty_i if quality_i >= 5 else 0 (per GDPO Β§3.2)
|
| 9 |
+
3. completeness_reward = deterministic, did the story actually end?
|
| 10 |
+
|
| 11 |
+
The two judge-driven rewards share ONE judge call per group via a small cache,
|
| 12 |
+
so we don't pay for the same scoring twice.
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
export OPENROUTER_API_KEY="sk-or-..."
|
| 16 |
+
python train.py
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import json
|
| 21 |
+
import statistics
|
| 22 |
+
import requests
|
| 23 |
+
import wandb
|
| 24 |
+
from datasets import load_dataset
|
| 25 |
+
from transformers import TrainerCallback
|
| 26 |
+
from trl import GRPOConfig, GRPOTrainer
|
| 27 |
+
from peft import LoraConfig
|
| 28 |
+
|
| 29 |
+
# ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
BASE_MODEL = "Qwen/Qwen3-4B-Instruct-2507"
|
| 31 |
+
JUDGE_MODEL = "google/gemini-3-flash-preview"
|
| 32 |
+
OPENROUTER_API_KEY = "sk-or-v1-REDACTED-BEFORE-UPLOAD"
|
| 33 |
+
|
| 34 |
+
NUM_GENERATIONS = 16 # group size β the diversity sample pool
|
| 35 |
+
GROUP_DIV_BONUS = 0.5 # weight on group_diversity inside quality reward
|
| 36 |
+
QUALITY_GATE = 5.0 # novelty only counts if quality >= this (GDPO conditioning)
|
| 37 |
+
MAX_COMPLETION_TOKENS = 4096 # give stories room to actually end
|
| 38 |
+
LEARNING_RATE = 5e-6
|
| 39 |
+
BATCH_SIZE = 1 # prompts per micro-batch
|
| 40 |
+
GRAD_ACCUM = 2 # β optimizer step every 2 prompts (~250 steps over run)
|
| 41 |
+
NUM_TRAIN_EPOCHS = 2
|
| 42 |
+
TRAIN_SIZE = 250
|
| 43 |
+
EVAL_SIZE = 5
|
| 44 |
+
OUTPUT_DIR = "./grpo-diverse"
|
| 45 |
+
|
| 46 |
+
# LoRA
|
| 47 |
+
LORA_R = 128
|
| 48 |
+
LORA_ALPHA = 256
|
| 49 |
+
LORA_TARGET_MODULES = ["q_proj", "k_proj", "v_proj", "o_proj"]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ββ Judge prompt + call βββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
JUDGE_SYSTEM_PROMPT = """You are evaluating creative writing responses to the same writing prompt.
|
| 54 |
+
|
| 55 |
+
## Scoring Instructions
|
| 56 |
+
|
| 57 |
+
For EACH sample, score:
|
| 58 |
+
|
| 59 |
+
### quality (1-10)
|
| 60 |
+
- 1-3: Incoherent, nonsensical, or unfinished. Sentences contradict each other. Plot makes no sense. Abrupt cutoff mid-sentence.
|
| 61 |
+
- 4-5: Readable but bland or generic. Makes sense but nothing memorable.
|
| 62 |
+
- 6-7: Well-written, engaging, coherent from start to finish.
|
| 63 |
+
- 8-10: Genuinely compelling. Strong voice, clear arc, emotional impact.
|
| 64 |
+
|
| 65 |
+
IMPORTANT quality penalties:
|
| 66 |
+
- If the story cuts off mid-sentence or clearly doesn't end, cap quality at 4.
|
| 67 |
+
- If the story is nonsensical (events don't follow logically, characters act without motivation), cap quality at 3.
|
| 68 |
+
- If the tone MISMATCHES the prompt (e.g., a clearly humorous/absurd prompt is answered with a completely serious, solemn story), subtract 2 points.
|
| 69 |
+
|
| 70 |
+
### novelty (1-10)
|
| 71 |
+
How DIFFERENT is this sample from all other samples in this set?
|
| 72 |
+
|
| 73 |
+
Rate HIGH (7-10) if this sample uses a different:
|
| 74 |
+
- Genre or tone (comedy vs horror vs literary fiction vs thriller vs satire)
|
| 75 |
+
- Narrative structure (dialogue-driven vs prose vs epistolary vs journal vs screenplay)
|
| 76 |
+
- Point of view (first person vs third person vs second person)
|
| 77 |
+
- Emotional register (funny vs melancholic vs tense vs whimsical vs deadpan)
|
| 78 |
+
|
| 79 |
+
Rate LOW (1-3) if this sample:
|
| 80 |
+
- Uses the same genre/tone as most other samples
|
| 81 |
+
- Opens with a similar sentence structure
|
| 82 |
+
- Relies on the same rhetorical patterns as other samples
|
| 83 |
+
|
| 84 |
+
IMPORTANT novelty penalties:
|
| 85 |
+
- The pattern "It wasn't X, it was Y" or "He didn't X. He *Y*-ed" is a specific rhetorical device. If more than 2 samples use this negation-then-reframe pattern, score those samples 1-3 for novelty.
|
| 86 |
+
- If all samples use heavy italic emphasis (*word*) for the same dramatic effect, this is NOT diversity. Note it and reduce novelty scores.
|
| 87 |
+
- Surface-level changes (different character names, different settings) with the same tone and structure = LOW novelty.
|
| 88 |
+
|
| 89 |
+
### group_diversity (1-10)
|
| 90 |
+
Does this SET as a whole cover genuinely different creative territory?
|
| 91 |
+
|
| 92 |
+
- 1-3: All samples share the same tone, genre, and emotional register. Even if plots differ, they all "feel" the same to read. All are dark literary fantasy, or all are solemn and atmospheric, or all are action thrillers.
|
| 93 |
+
- 4-5: Some structural variety (different openings, different plot directions) but the tone/mood is largely uniform. A reader would notice they all sound like the same author in the same mood.
|
| 94 |
+
- 6-7: Clear variety in at least TWO of: genre, tone, structure, POV. For example, one sample is humorous while others are serious, or one is dialogue-heavy while others are prose.
|
| 95 |
+
- 8-10: Multiple genuinely distinct approaches. The set includes different genres (comedy AND drama AND thriller), different structures (prose AND dialogue AND epistolary), different emotional registers (funny AND melancholic AND tense). A reader would think these came from different authors.
|
| 96 |
+
|
| 97 |
+
CRITICAL: A set of 8 dark atmospheric fantasy stories with different plots is a 2-3, not a 6. Diversity means TONAL and STRUCTURAL variety, not just plot variety.
|
| 98 |
+
|
| 99 |
+
Respond with ONLY valid JSON, no markdown fences:
|
| 100 |
+
{"scores": [{"quality": N, "novelty": N}, ...], "group_diversity": N}"""
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def call_judge(prompt: str, completions: list[str]) -> dict:
|
| 104 |
+
"""Send all completions to the judge in one API call."""
|
| 105 |
+
samples = ""
|
| 106 |
+
for i, c in enumerate(completions):
|
| 107 |
+
samples += f"\n--- Sample {i+1} ---\n{c}\n"
|
| 108 |
+
|
| 109 |
+
user_prompt = f"""You are evaluating {len(completions)} creative writing responses to the same writing prompt.
|
| 110 |
+
|
| 111 |
+
WRITING PROMPT:
|
| 112 |
+
{prompt}
|
| 113 |
+
|
| 114 |
+
SAMPLES:{samples}
|
| 115 |
+
|
| 116 |
+
Respond with ONLY valid JSON, no markdown fences:
|
| 117 |
+
{{"scores": [{{"quality": N, "novelty": N}}, ...], "group_diversity": N}}"""
|
| 118 |
+
|
| 119 |
+
resp = requests.post(
|
| 120 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 121 |
+
headers={
|
| 122 |
+
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
| 123 |
+
"Content-Type": "application/json",
|
| 124 |
+
},
|
| 125 |
+
json={
|
| 126 |
+
"model": JUDGE_MODEL,
|
| 127 |
+
"messages": [
|
| 128 |
+
{"role": "system", "content": JUDGE_SYSTEM_PROMPT},
|
| 129 |
+
{"role": "user", "content": user_prompt},
|
| 130 |
+
],
|
| 131 |
+
"temperature": 0.2,
|
| 132 |
+
},
|
| 133 |
+
timeout=120,
|
| 134 |
+
)
|
| 135 |
+
resp.raise_for_status()
|
| 136 |
+
|
| 137 |
+
text = resp.json()["choices"][0]["message"]["content"].strip()
|
| 138 |
+
if text.startswith("```"):
|
| 139 |
+
text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
| 140 |
+
return json.loads(text)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ββ Judge cache: one call per group, shared across reward fns ββββββ
|
| 144 |
+
_judge_cache: dict = {}
|
| 145 |
+
_judge_cache_order: list = []
|
| 146 |
+
_JUDGE_CACHE_MAX = 32
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def cached_judge(prompt: str, completions: list[str]) -> dict:
|
| 150 |
+
"""Memoized judge call. quality_reward and novelty_reward both hit this
|
| 151 |
+
with the same (prompt, completions) tuple within a step β only one HTTP
|
| 152 |
+
request actually fires. Returns a neutral fallback on failure."""
|
| 153 |
+
key = hash((prompt, tuple(completions)))
|
| 154 |
+
if key in _judge_cache:
|
| 155 |
+
return _judge_cache[key]
|
| 156 |
+
try:
|
| 157 |
+
result = call_judge(prompt, completions)
|
| 158 |
+
except Exception as e:
|
| 159 |
+
print(f" [judge error] {e}")
|
| 160 |
+
result = {
|
| 161 |
+
"scores": [{"quality": 5, "novelty": 5} for _ in completions],
|
| 162 |
+
"group_diversity": 5,
|
| 163 |
+
"_error": str(e),
|
| 164 |
+
}
|
| 165 |
+
_judge_cache[key] = result
|
| 166 |
+
_judge_cache_order.append(key)
|
| 167 |
+
while len(_judge_cache_order) > _JUDGE_CACHE_MAX:
|
| 168 |
+
_judge_cache.pop(_judge_cache_order.pop(0), None)
|
| 169 |
+
return result
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 173 |
+
def _extract_text(c) -> str:
|
| 174 |
+
if isinstance(c, list):
|
| 175 |
+
return c[-1]["content"] if c else ""
|
| 176 |
+
if isinstance(c, dict):
|
| 177 |
+
return c.get("content", str(c))
|
| 178 |
+
return str(c)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _normalize_prompt(p) -> str:
|
| 182 |
+
if isinstance(p, list):
|
| 183 |
+
return p[-1]["content"]
|
| 184 |
+
return str(p)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _group_by_prompt(prompts: list[str]) -> dict:
|
| 188 |
+
groups: dict = {}
|
| 189 |
+
for i, p in enumerate(prompts):
|
| 190 |
+
groups.setdefault(p, []).append(i)
|
| 191 |
+
return groups
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _normalize_prompts_kwarg(completions, kwargs) -> list[str]:
|
| 195 |
+
raw = kwargs.get("prompts")
|
| 196 |
+
if raw is None:
|
| 197 |
+
return ["unknown"] * len(completions)
|
| 198 |
+
return [_normalize_prompt(p) for p in raw]
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ββ Reward functions βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 202 |
+
def quality_reward(completions, **kwargs) -> list[float]:
|
| 203 |
+
"""quality_i + GROUP_DIV_BONUS * group_diversity (per sample)."""
|
| 204 |
+
texts = [_extract_text(c) for c in completions]
|
| 205 |
+
prompts = _normalize_prompts_kwarg(completions, kwargs)
|
| 206 |
+
|
| 207 |
+
rewards = [0.0] * len(texts)
|
| 208 |
+
qualities = []
|
| 209 |
+
group_divs = []
|
| 210 |
+
|
| 211 |
+
for prompt_str, indices in _group_by_prompt(prompts).items():
|
| 212 |
+
group_texts = [texts[i] for i in indices]
|
| 213 |
+
result = cached_judge(prompt_str, group_texts)
|
| 214 |
+
scores = result["scores"]
|
| 215 |
+
gd = float(result.get("group_diversity", 5))
|
| 216 |
+
group_divs.append(gd)
|
| 217 |
+
|
| 218 |
+
for j, idx in enumerate(indices):
|
| 219 |
+
q = float(scores[j].get("quality", 5))
|
| 220 |
+
qualities.append(q)
|
| 221 |
+
rewards[idx] = q + GROUP_DIV_BONUS * gd
|
| 222 |
+
|
| 223 |
+
if wandb.run is not None and qualities:
|
| 224 |
+
wandb.log({
|
| 225 |
+
"judge/avg_quality": statistics.fmean(qualities),
|
| 226 |
+
"judge/quality_std": statistics.pstdev(qualities) if len(qualities) > 1 else 0.0,
|
| 227 |
+
"judge/avg_group_diversity": statistics.fmean(group_divs),
|
| 228 |
+
"judge/group_diversity_std": statistics.pstdev(group_divs) if len(group_divs) > 1 else 0.0,
|
| 229 |
+
})
|
| 230 |
+
|
| 231 |
+
print(f" [quality] mean={statistics.fmean(rewards):.2f} | gd_avg={statistics.fmean(group_divs):.2f}")
|
| 232 |
+
return rewards
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def novelty_reward(completions, **kwargs) -> list[float]:
|
| 236 |
+
"""novelty_i if quality_i >= QUALITY_GATE else 0.
|
| 237 |
+
|
| 238 |
+
Conditioning kills the gaming strategy where the model produces
|
| 239 |
+
incoherent-but-different outputs to farm the novelty signal.
|
| 240 |
+
"""
|
| 241 |
+
texts = [_extract_text(c) for c in completions]
|
| 242 |
+
prompts = _normalize_prompts_kwarg(completions, kwargs)
|
| 243 |
+
|
| 244 |
+
rewards = [0.0] * len(texts)
|
| 245 |
+
novelties = []
|
| 246 |
+
pass_gate = 0
|
| 247 |
+
|
| 248 |
+
for prompt_str, indices in _group_by_prompt(prompts).items():
|
| 249 |
+
group_texts = [texts[i] for i in indices]
|
| 250 |
+
result = cached_judge(prompt_str, group_texts)
|
| 251 |
+
scores = result["scores"]
|
| 252 |
+
|
| 253 |
+
for j, idx in enumerate(indices):
|
| 254 |
+
q = float(scores[j].get("quality", 5))
|
| 255 |
+
# Gemini sometimes truncates "novelty" to "novel"
|
| 256 |
+
n = float(scores[j].get("novelty") or scores[j].get("novel") or 5)
|
| 257 |
+
novelties.append(n)
|
| 258 |
+
if q >= QUALITY_GATE:
|
| 259 |
+
rewards[idx] = n
|
| 260 |
+
pass_gate += 1
|
| 261 |
+
else:
|
| 262 |
+
rewards[idx] = 0.0
|
| 263 |
+
|
| 264 |
+
if wandb.run is not None and novelties:
|
| 265 |
+
wandb.log({
|
| 266 |
+
"judge/avg_novelty": statistics.fmean(novelties),
|
| 267 |
+
"judge/novelty_std": statistics.pstdev(novelties) if len(novelties) > 1 else 0.0,
|
| 268 |
+
"judge/novelty_conditioned_pct": pass_gate / len(novelties),
|
| 269 |
+
})
|
| 270 |
+
return rewards
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def completeness_reward(completions, **kwargs) -> list[float]:
|
| 274 |
+
"""Deterministic shaping: did the story actually end?
|
| 275 |
+
|
| 276 |
+
1.0 if it ends with a sentence terminator, 0.3 if it produced
|
| 277 |
+
substantial text but trails off, 0.0 otherwise. Cheap, zero
|
| 278 |
+
judge-noise variance, directly addresses truncation.
|
| 279 |
+
"""
|
| 280 |
+
enders = ".!?\"'"
|
| 281 |
+
rewards = []
|
| 282 |
+
for c in completions:
|
| 283 |
+
text = _extract_text(c).strip()
|
| 284 |
+
if not text:
|
| 285 |
+
rewards.append(0.0)
|
| 286 |
+
elif text[-1] in enders:
|
| 287 |
+
rewards.append(1.0)
|
| 288 |
+
elif len(text) > 100:
|
| 289 |
+
rewards.append(0.3)
|
| 290 |
+
else:
|
| 291 |
+
rewards.append(0.0)
|
| 292 |
+
|
| 293 |
+
if wandb.run is not None and rewards:
|
| 294 |
+
complete_rate = sum(1 for r in rewards if r >= 1.0) / len(rewards)
|
| 295 |
+
wandb.log({
|
| 296 |
+
"judge/avg_completeness": statistics.fmean(rewards),
|
| 297 |
+
"judge/completeness_rate": complete_rate,
|
| 298 |
+
})
|
| 299 |
+
return rewards
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# ββ Dataset βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 303 |
+
def load_writing_prompts():
|
| 304 |
+
"""
|
| 305 |
+
250 train + 5 eval prompts.
|
| 306 |
+
|
| 307 |
+
TODO (manual): audit these for tonal variety. Aim for:
|
| 308 |
+
- >=20% clearly comedic / absurd / lighthearted
|
| 309 |
+
- >=20% constrained ("in exactly 3 sentences", "as a diary entry", etc.)
|
| 310 |
+
The judge has more surface area to reward diversity when the prompt
|
| 311 |
+
pool itself spans tones and constraints.
|
| 312 |
+
"""
|
| 313 |
+
ds = load_dataset("euclaise/writingprompts", split="train")
|
| 314 |
+
|
| 315 |
+
def format_prompt(example):
|
| 316 |
+
# NEVER truncate. The judge needs the full prompt for context, and
|
| 317 |
+
# the model needs to see exactly what we'll be scoring against.
|
| 318 |
+
text = (example.get("prompt") or example.get("text") or "").strip()
|
| 319 |
+
return {
|
| 320 |
+
"prompt": [
|
| 321 |
+
{"role": "user", "content": f"Write a short creative story based on this prompt:\n\n{text}"}
|
| 322 |
+
]
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
total = TRAIN_SIZE + EVAL_SIZE
|
| 326 |
+
ds = ds.map(format_prompt, remove_columns=ds.column_names)
|
| 327 |
+
ds = ds.shuffle(seed=42).select(range(total))
|
| 328 |
+
eval_ds = ds.select(range(TRAIN_SIZE, total))
|
| 329 |
+
train_ds = ds.select(range(TRAIN_SIZE))
|
| 330 |
+
return train_ds, eval_ds
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# ββ Eval-side metrics (compute, log, but don't reward on these) ββββ
|
| 334 |
+
def _unique_5word_openers(samples: list[str]) -> int:
|
| 335 |
+
openers = set()
|
| 336 |
+
for s in samples:
|
| 337 |
+
words = s.strip().split()[:5]
|
| 338 |
+
if words:
|
| 339 |
+
openers.add(" ".join(w.lower() for w in words))
|
| 340 |
+
return len(openers)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def _vocab_diversity(samples: list[str]) -> float:
|
| 344 |
+
"""Type-token ratio across all samples in a group."""
|
| 345 |
+
tokens = []
|
| 346 |
+
for s in samples:
|
| 347 |
+
tokens.extend(w.lower().strip(".,!?\"'") for w in s.split())
|
| 348 |
+
if not tokens:
|
| 349 |
+
return 0.0
|
| 350 |
+
return len(set(tokens)) / len(tokens)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
# ββ Eval callback ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 354 |
+
class EvalSampleCallback(TrainerCallback):
|
| 355 |
+
"""Every 50 steps: generate samples on held-out prompts, judge them
|
| 356 |
+
once per prompt, and dump everything (samples + raw judge response) to
|
| 357 |
+
disk for offline inspection."""
|
| 358 |
+
|
| 359 |
+
def __init__(self, eval_prompts, tokenizer, gen_count=8):
|
| 360 |
+
self.eval_prompts = eval_prompts
|
| 361 |
+
self.tokenizer = tokenizer
|
| 362 |
+
self.gen_count = gen_count
|
| 363 |
+
|
| 364 |
+
def on_step_end(self, args, state, control, model=None, **kwargs):
|
| 365 |
+
if state.global_step == 0 or state.global_step % 50 != 0:
|
| 366 |
+
return
|
| 367 |
+
if model is None:
|
| 368 |
+
return
|
| 369 |
+
|
| 370 |
+
import torch
|
| 371 |
+
model.eval()
|
| 372 |
+
step = state.global_step
|
| 373 |
+
print(f"\n{'='*60}\n EVAL SAMPLES @ step {step}\n{'='*60}")
|
| 374 |
+
|
| 375 |
+
all_data = {}
|
| 376 |
+
all_unique_openers = []
|
| 377 |
+
all_vocab_div = []
|
| 378 |
+
|
| 379 |
+
for pi, prompt_msgs in enumerate(self.eval_prompts):
|
| 380 |
+
prompt_text = prompt_msgs[-1]["content"]
|
| 381 |
+
print(f"\nββ Prompt {pi+1}: {prompt_text[:100]}...")
|
| 382 |
+
|
| 383 |
+
input_ids = self.tokenizer.apply_chat_template(
|
| 384 |
+
prompt_msgs, add_generation_prompt=True, return_tensors="pt"
|
| 385 |
+
).to(model.device)
|
| 386 |
+
|
| 387 |
+
samples = []
|
| 388 |
+
for si in range(self.gen_count):
|
| 389 |
+
with torch.no_grad():
|
| 390 |
+
out = model.generate(
|
| 391 |
+
input_ids,
|
| 392 |
+
max_new_tokens=MAX_COMPLETION_TOKENS,
|
| 393 |
+
temperature=1.0,
|
| 394 |
+
do_sample=True,
|
| 395 |
+
top_p=0.95,
|
| 396 |
+
)
|
| 397 |
+
text = self.tokenizer.decode(out[0][input_ids.shape[1]:], skip_special_tokens=True)
|
| 398 |
+
samples.append(text)
|
| 399 |
+
print(f" [Sample {si+1}] {text[:200]}...")
|
| 400 |
+
|
| 401 |
+
# Judge the eval samples (fresh call, not from train cache)
|
| 402 |
+
try:
|
| 403 |
+
judge_result = call_judge(prompt_text, samples)
|
| 404 |
+
except Exception as e:
|
| 405 |
+
judge_result = {"_error": str(e)}
|
| 406 |
+
|
| 407 |
+
# Eval-only metrics: deterministic, no extra API calls
|
| 408 |
+
openers = _unique_5word_openers(samples)
|
| 409 |
+
vocab = _vocab_diversity(samples)
|
| 410 |
+
all_unique_openers.append(openers)
|
| 411 |
+
all_vocab_div.append(vocab)
|
| 412 |
+
|
| 413 |
+
gd = judge_result.get("group_diversity") if isinstance(judge_result, dict) else None
|
| 414 |
+
if isinstance(gd, (int, float)) and gd < 3:
|
| 415 |
+
print(f" [LOW DIVERSITY] group_diversity={gd} on prompt {pi+1}")
|
| 416 |
+
|
| 417 |
+
all_data[prompt_text] = {
|
| 418 |
+
"samples": samples,
|
| 419 |
+
"judge": judge_result,
|
| 420 |
+
"unique_openers": openers,
|
| 421 |
+
"vocab_diversity": vocab,
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
# Aggregate eval metrics β wandb
|
| 425 |
+
if wandb.run is not None and all_unique_openers:
|
| 426 |
+
wandb.log({
|
| 427 |
+
"eval/unique_openers": statistics.fmean(all_unique_openers),
|
| 428 |
+
"eval/vocab_diversity": statistics.fmean(all_vocab_div),
|
| 429 |
+
})
|
| 430 |
+
|
| 431 |
+
save_dir = os.path.join(args.output_dir, "eval_samples")
|
| 432 |
+
os.makedirs(save_dir, exist_ok=True)
|
| 433 |
+
save_path = os.path.join(save_dir, f"step_{step}.json")
|
| 434 |
+
with open(save_path, "w") as f:
|
| 435 |
+
json.dump(all_data, f, indent=2)
|
| 436 |
+
print(f"\n [saved] {save_path}\n{'='*60}\n")
|
| 437 |
+
model.train()
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
# ββ Training ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 441 |
+
def main():
|
| 442 |
+
print(f"Model: {BASE_MODEL}")
|
| 443 |
+
print(f"Judge: {JUDGE_MODEL}")
|
| 444 |
+
print(f"Group size: {NUM_GENERATIONS}")
|
| 445 |
+
print(f"Quality gate: {QUALITY_GATE}")
|
| 446 |
+
print(f"Group div bonus: {GROUP_DIV_BONUS}")
|
| 447 |
+
|
| 448 |
+
dataset, eval_dataset = load_writing_prompts()
|
| 449 |
+
print(f"Dataset: {len(dataset)} train, {len(eval_dataset)} eval")
|
| 450 |
+
|
| 451 |
+
lora_config = LoraConfig(
|
| 452 |
+
r=LORA_R,
|
| 453 |
+
lora_alpha=LORA_ALPHA,
|
| 454 |
+
target_modules=LORA_TARGET_MODULES,
|
| 455 |
+
task_type="CAUSAL_LM",
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
training_config = GRPOConfig(
|
| 459 |
+
output_dir=OUTPUT_DIR,
|
| 460 |
+
num_train_epochs=NUM_TRAIN_EPOCHS,
|
| 461 |
+
per_device_train_batch_size=BATCH_SIZE,
|
| 462 |
+
num_generations=NUM_GENERATIONS,
|
| 463 |
+
max_completion_length=MAX_COMPLETION_TOKENS,
|
| 464 |
+
learning_rate=LEARNING_RATE,
|
| 465 |
+
logging_steps=1,
|
| 466 |
+
save_steps=100,
|
| 467 |
+
bf16=True,
|
| 468 |
+
gradient_accumulation_steps=GRAD_ACCUM,
|
| 469 |
+
# vLLM
|
| 470 |
+
use_vllm=True,
|
| 471 |
+
vllm_mode="colocate",
|
| 472 |
+
vllm_gpu_memory_utilization=0.3,
|
| 473 |
+
vllm_max_model_length=4096,
|
| 474 |
+
# GRPO sampling
|
| 475 |
+
temperature=1.0,
|
| 476 |
+
# Dr. GRPO: kill KL penalty and reward std normalization
|
| 477 |
+
beta=0.0,
|
| 478 |
+
scale_rewards=False,
|
| 479 |
+
# GDPO: decoupled per-objective normalization before summing
|
| 480 |
+
multi_objective_aggregation="normalize_then_sum",
|
| 481 |
+
report_to="wandb",
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
trainer = GRPOTrainer(
|
| 485 |
+
model=BASE_MODEL,
|
| 486 |
+
reward_funcs=[quality_reward, novelty_reward, completeness_reward],
|
| 487 |
+
args=training_config,
|
| 488 |
+
train_dataset=dataset,
|
| 489 |
+
peft_config=lora_config,
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
eval_prompts = [ex["prompt"] for ex in eval_dataset]
|
| 493 |
+
eval_cb = EvalSampleCallback(eval_prompts, trainer.processing_class)
|
| 494 |
+
trainer.add_callback(eval_cb)
|
| 495 |
+
|
| 496 |
+
print("\nββ Starting training ββ")
|
| 497 |
+
trainer.train()
|
| 498 |
+
trainer.save_model(os.path.join(OUTPUT_DIR, "final"))
|
| 499 |
+
print("Done.")
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
if __name__ == "__main__":
|
| 503 |
+
main()
|