Pranav2748 commited on
Commit
f6ff951
Β·
verified Β·
1 Parent(s): 670d882

Add prior_run/train.py (API key redacted)

Browse files
Files changed (1) hide show
  1. 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()