creative-writing-llm / src /train_grpo.py
Pranav2748's picture
Add src
cbc33fe verified
Raw
History Blame Contribute Delete
9.52 kB
"""
GRPO training for E0 (quality-only baseline), E1 (div-grpo-individual),
E2 (div-grpo-group). The three arms differ ONLY by YAML config -- same code
path, same data, same seed -- so any difference between them is attributable
to the reward configuration and nothing else.
Aggregation is GDPO (arXiv 2601.05242, Liu et al., NVIDIA): group-wise
normalization per reward channel, then batch-wise advantage normalization.
TRL 1.10 implements this as multi_objective_aggregation="normalize_then_sum".
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from dataclasses import asdict
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
def build_dataset(prompts, tokenizer):
from datasets import Dataset
from data import chat_messages
return Dataset.from_list([
{"prompt": chat_messages(p["prompt"]), "prompt_id": p["id"]}
for p in prompts
])
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", required=True)
ap.add_argument("--max-steps", type=int, default=None, help="override (smoke tests)")
ap.add_argument("--smoke", action="store_true")
args = ap.parse_args()
cfg = yaml.safe_load(open(args.config))
name = cfg["name"]
if args.smoke:
name = f"{name}-smoke"
import torch
import wandb
from peft import LoraConfig
from transformers import AutoTokenizer, TrainerCallback
from trl import GRPOConfig, GRPOTrainer
import logbook
from data import load_prompts
from judge import build_judge
from rewards import RewardConfig, RewardEngine
out_dir = ROOT / "outputs" / name
out_dir.mkdir(parents=True, exist_ok=True)
steps = args.max_steps or cfg["train"]["max_steps"]
model_id = cfg["model"]
G = cfg["train"]["num_generations"]
# ---- wandb -----------------------------------------------------------
run = None
if cfg.get("wandb", True) and os.environ.get("WANDB_API_KEY"):
run = wandb.init(
project=os.environ.get("WANDB_PROJECT", "div-grpo"),
name=name, config=cfg, reinit=True,
mode=os.environ.get("WANDB_MODE", "online"),
)
logbook.note(f"START {name}",
f"```yaml\n{yaml.safe_dump(cfg, sort_keys=False)}```\n"
f"steps={steps} G={G} model={model_id}")
# ---- reward engine ---------------------------------------------------
rcfg = RewardConfig(
arm=cfg["reward"]["arm"],
alpha=cfg["reward"].get("alpha", 0.5),
gamma=cfg["reward"].get("gamma", 0.5),
tau=cfg["reward"].get("tau", 5.0),
)
judge = build_judge(
model=cfg["judge"]["model"],
cache_path=str(ROOT / "cache" / "judge.sqlite"),
concurrency=cfg["judge"].get("concurrency", 12),
)
engine = RewardEngine(rcfg, judge, wandb_run=run, log_prefix="train")
reward_funcs = engine.make_reward_funcs()
weights = rcfg.weights()
print(f"[arm {rcfg.arm}] channels={rcfg.channels()} weights={weights} tau={rcfg.tau}")
# ---- data ------------------------------------------------------------
tokenizer = AutoTokenizer.from_pretrained(model_id)
train_prompts = load_prompts("train", ROOT / "data")
if args.smoke:
train_prompts = train_prompts[:64]
train_ds = build_dataset(train_prompts, tokenizer)
# ---- LoRA ------------------------------------------------------------
lora = LoraConfig(
r=cfg["lora"]["r"],
lora_alpha=cfg["lora"]["alpha"],
lora_dropout=cfg["lora"].get("dropout", 0.0),
target_modules=cfg["lora"]["target_modules"],
task_type="CAUSAL_LM",
bias="none",
)
gcfg = GRPOConfig(
output_dir=str(out_dir),
max_steps=steps,
per_device_train_batch_size=cfg["train"]["per_device_train_batch_size"],
gradient_accumulation_steps=cfg["train"]["gradient_accumulation_steps"],
num_generations=G,
max_completion_length=cfg["train"]["max_completion_length"],
# TRL 1.10 dropped max_prompt_length; vLLM's window is the control now.
vllm_max_model_length=cfg["train"].get("vllm_max_model_length", 2048),
# NOT masking truncated completions: a truncated story is gated to the
# bottom of every reward channel, and we want that negative gradient to
# reach the policy. Masking would make truncation free.
mask_truncated_completions=False,
learning_rate=cfg["train"]["learning_rate"],
lr_scheduler_type=cfg["train"].get("lr_scheduler_type", "constant_with_warmup"),
warmup_steps=cfg["train"].get("warmup_steps", 10),
beta=cfg["train"]["beta"],
temperature=cfg["train"].get("temperature", 1.0),
top_p=cfg["train"].get("top_p", 1.0),
# GDPO: per-reward group normalization, then batch-level advantage norm
multi_objective_aggregation="normalize_then_sum",
reward_weights=weights,
scale_rewards=cfg["train"].get("scale_rewards", "group"),
bf16=True,
gradient_checkpointing=True,
# Liger fuses RMSNorm/SwiGLU/RoPE and the LM-head cross-entropy, which
# is where the peak lives: the logits tensor is
# micro_batch x seq x 151936 vocab, and it was the allocation that OOMed.
use_liger_kernel=cfg["train"].get("use_liger_kernel", True),
torch_empty_cache_steps=cfg["train"].get("torch_empty_cache_steps", 8),
use_vllm=True,
vllm_mode="colocate",
vllm_gpu_memory_utilization=cfg["train"]["vllm_gpu_memory_utilization"],
logging_steps=1,
save_steps=cfg["train"].get("save_steps", 50),
save_total_limit=cfg["train"].get("save_total_limit", 7),
# Checkpoints exist only to EVALUATE intermediate policies (ckpt_study),
# never to resume training. Without this, HF writes a 505MB optimizer.pt
# beside a 253MB adapter -- 3x the disk for state we never read. This was
# set in the YAML from E1 onward but not passed through until now.
save_only_model=cfg["train"].get("save_only_model", True),
seed=cfg.get("seed", 42),
report_to=["wandb"] if run else [],
run_name=name,
)
trainer = GRPOTrainer(
model=model_id,
reward_funcs=reward_funcs,
args=gcfg,
train_dataset=train_ds,
peft_config=lora,
)
# ---- periodic reward-hacking guardrail -------------------------------
class Guardrail(TrainerCallback):
"""Stop the run if diversity climbs while quality/validity collapses.
The brief's guardrail: 'if reward hacking appears (deviation up, quality
flat/down, or degenerate text passing gates), stop the run'. We compare a
trailing window against the opening baseline rather than step-to-step,
because GRPO reward traces are far too noisy for a point comparison.
"""
WINDOW = 25
def on_step_end(self, a, state, control, **kw):
h = engine.history
if len(h) < self.WINDOW * 2:
return
base = h[:self.WINDOW]
recent = h[-self.WINDOW:]
def mean(rows, f):
return sum(f(r) for r in rows) / len(rows)
gate0, gate1 = mean(base, lambda r: r.gate_pass), mean(recent, lambda r: r.gate_pass)
q0, q1 = mean(base, lambda r: r.mean_quality_passing), mean(recent, lambda r: r.mean_quality_passing)
d0, d1 = mean(base, lambda r: r.mean_deviation), mean(recent, lambda r: r.mean_deviation)
msg = None
if gate1 < 0.55 and gate1 < gate0 - 0.25:
msg = f"gate pass collapsed {gate0:.2f}->{gate1:.2f}"
elif d1 > d0 + 0.05 and q1 < q0 - 1.0:
msg = f"reward hacking: deviation {d0:.3f}->{d1:.3f} while quality {q0:.2f}->{q1:.2f}"
if msg:
logbook.note(f"GUARDRAIL TRIP {name}", msg, level="ALERT")
print(f"\n!!! GUARDRAIL: {msg} -- stopping at step {state.global_step}\n", flush=True)
control.should_training_stop = True
trainer.add_callback(Guardrail())
print(f"\n── training {name}: {steps} steps ──", flush=True)
trainer.train()
final = out_dir / "final"
trainer.save_model(str(final))
tokenizer.save_pretrained(str(final))
hist = [asdict(s) for s in engine.history]
json.dump(hist, open(out_dir / "reward_history.json", "w"), indent=1)
# TRL's own log history carries per-token policy entropy, KL and clip ratio.
# Entropy is only present on the non-liger loss path (compute_liger_loss logs
# just clip_ratio and kl), which is why use_liger_kernel is disabled.
json.dump(trainer.state.log_history,
open(out_dir / "trl_log_history.json", "w"), indent=1)
ent = [h["entropy"] for h in trainer.state.log_history if "entropy" in h]
print(f"entropy logged for {len(ent)} steps"
+ (f" | first={ent[0]:.4f} last={ent[-1]:.4f}" if ent else " -- MISSING!"))
cost = judge.cost_estimate(cfg["judge"]["price_in"], cfg["judge"]["price_out"])
json.dump(cost, open(out_dir / "judge_cost.json", "w"), indent=1)
print("judge cost:", cost)
logbook.note(f"DONE {name}",
f"adapter: `{final}`\n\njudge cost: `{json.dumps(cost)}`")
logbook.checkpoint(f"after {name}")
if run:
run.finish()
return 0
if __name__ == "__main__":
sys.exit(main())