openenv
leniencybench / train.py
shreyas-garg's picture
Mirror of GitHub source: OpenEnv-compliant LeniencyBench environment + training scripts
6b4f87f verified
Raw
History Blame Contribute Delete
17.2 kB
"""End-to-end training pipeline: SFT warm-up -> GRPO.
Designed to run top-to-bottom in Colab or on HF compute. Matches the organizer
guidance: verifier-based rewards, multiple independent reward components,
SFT warm-start before RL, and per-component logging.
Usage (Colab / HF Job):
!pip install -q unsloth trl datasets wandb
!python train.py
Toggle QUICK_MODE=True for a 5-min pipeline validation on Colab free tier.
Set to False on onsite HF compute for the real run.
"""
from __future__ import annotations
import os
import random
import sys
import warnings
from typing import Optional
import numpy as np
import torch
# Silence transformers generation-config spam during offline eval
warnings.filterwarnings("ignore", message="Both `max_new_tokens`.*")
warnings.filterwarnings("ignore", category=FutureWarning, module="transformers")
os.environ["TRANSFORMERS_VERBOSITY"] = "error"
# Module-level globals; real values set in main() after GPU detection.
USE_BF16: bool = False
USE_FP16: bool = False
from drift_env.dataset import build_dataset, dataset_stats
from drift_env.prompts import SYSTEM_PROMPT
from drift_env.training.rewards import (
reward_compliance,
reward_appropriateness,
reward_drift_bonus,
)
# ---------------------------------------------------------------------------
# Config (edit here or via env vars)
# ---------------------------------------------------------------------------
QUICK_MODE = os.getenv("QUICK_MODE", "true").lower() == "true"
# Model / hardware
MODEL_NAME = os.getenv("MODEL_NAME", "unsloth/Qwen2.5-0.5B-Instruct" if QUICK_MODE
else "unsloth/Qwen2.5-3B-Instruct")
MAX_SEQ_LEN = int(os.getenv("MAX_SEQ_LEN", "4096"))
LOAD_IN_4BIT = os.getenv("LOAD_IN_4BIT", "true").lower() == "true"
# Data
N_EPISODES_TRAIN = 50 if QUICK_MODE else 800
N_EPISODES_EVAL = 5 if QUICK_MODE else 40
SEED = 42
# SFT
SFT_EPOCHS = 1
SFT_BATCH = 2 if QUICK_MODE else 4
SFT_LR = 2e-4
# GRPO
GRPO_NUM_GEN = 4 if QUICK_MODE else 8 # K completions per prompt
GRPO_MAX_STEPS = 50 if QUICK_MODE else 600
GRPO_BATCH = 1 if QUICK_MODE else 2
GRPO_GRAD_ACCUM = 4
GRPO_LR = 5e-6
GRPO_MAX_COMPLETION = 128
# LoRA
LORA_R = 16
LORA_ALPHA = 16
LORA_DROPOUT = 0.0
# Output
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./outputs")
WANDB_PROJECT = os.getenv("WANDB_PROJECT", "drift-env")
USE_WANDB = os.getenv("USE_WANDB", "false").lower() == "true"
def seed_all(s: int) -> None:
random.seed(s); np.random.seed(s); torch.manual_seed(s)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(s)
def _push_outputs_to_hub(label: str = "training outputs") -> None:
"""Push the entire OUTPUT_DIR to HF Hub. Safe to call multiple times.
Survives ephemeral HF Jobs containers — without this, work is lost when
the container terminates."""
hub_repo = os.getenv("HUB_REPO_ID")
if not hub_repo:
return
try:
from huggingface_hub import HfApi, create_repo
create_repo(hub_repo, repo_type="model", exist_ok=True, private=True,
token=os.getenv("HF_TOKEN"))
HfApi().upload_folder(
folder_path=OUTPUT_DIR,
repo_id=hub_repo,
repo_type="model",
token=os.getenv("HF_TOKEN"),
commit_message=label,
)
print(f"[hub] {label} pushed -> https://huggingface.co/{hub_repo}")
except Exception as e:
print(f"[hub] WARN: failed to push '{label}': {e}")
def _precision_flags() -> tuple[bool, bool]:
"""Return (use_bf16, use_fp16). T4 (Turing, CC 7.5) doesn't support bf16 —
only Ampere (CC 8.0) and later do. Fall back to fp16 on T4."""
if not torch.cuda.is_available():
return False, False
major, _ = torch.cuda.get_device_capability()
if major >= 8:
return True, False # Ampere+ — use bf16
return False, True # T4 etc — use fp16
# ---------------------------------------------------------------------------
# 1. Build train/eval datasets from our environment
# ---------------------------------------------------------------------------
def build_hf_datasets():
from datasets import Dataset
train_rows = build_dataset(n_episodes=N_EPISODES_TRAIN, start_seed=0)
eval_rows = build_dataset(n_episodes=N_EPISODES_EVAL, start_seed=10_000)
print(f"Train: {dataset_stats(train_rows)}")
print(f"Eval: {dataset_stats(eval_rows)}")
# SFT wants `prompt` + `completion` fields (chat template applied later)
# GRPO wants `prompt` + the extra columns used in the reward functions.
def to_chat(row, include_completion=False):
out = {
"prompt": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": row["prompt"]},
],
# Kept for TRL to forward into the reward funcs
"correct_action_hint": row["correct_action_hint"],
"email_kind": row["email_kind"] or "",
"can_earn_drift_bonus": bool(row["can_earn_drift_bonus"]),
"drift_sensitive_to": row["drift_sensitive_to"] or "",
"is_admin_email": bool(row["is_admin_email"]),
}
if include_completion:
out["completion"] = [{"role": "assistant", "content": row["correct_action_json"]}]
return out
sft_train = Dataset.from_list([to_chat(r, include_completion=True) for r in train_rows])
grpo_train = Dataset.from_list([to_chat(r) for r in train_rows])
grpo_eval = Dataset.from_list([to_chat(r) for r in eval_rows])
return sft_train, grpo_train, grpo_eval
# ---------------------------------------------------------------------------
# 2. Load Qwen via Unsloth with LoRA adapters
# ---------------------------------------------------------------------------
def load_model_and_tokenizer():
from unsloth import FastLanguageModel
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=MODEL_NAME,
max_seq_length=MAX_SEQ_LEN,
dtype=None, # auto
load_in_4bit=LOAD_IN_4BIT,
)
model = FastLanguageModel.get_peft_model(
model,
r=LORA_R,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
lora_alpha=LORA_ALPHA,
lora_dropout=LORA_DROPOUT,
bias="none",
use_gradient_checkpointing="unsloth",
random_state=SEED,
)
return model, tokenizer
# ---------------------------------------------------------------------------
# 3. SFT warm-up (1 epoch)
# ---------------------------------------------------------------------------
def run_sft(model, tokenizer, train_ds):
from trl import SFTTrainer, SFTConfig
args = SFTConfig(
output_dir=f"{OUTPUT_DIR}/sft",
num_train_epochs=SFT_EPOCHS,
per_device_train_batch_size=SFT_BATCH,
gradient_accumulation_steps=4,
learning_rate=SFT_LR,
logging_steps=10,
save_strategy="no",
bf16=USE_BF16,
fp16=USE_FP16,
report_to="wandb" if USE_WANDB else "none",
seed=SEED,
max_length=MAX_SEQ_LEN,
# Cap tokenization parallelism — Unsloth defaults to num_proc=cpu_count()
# which spawns 64+ workers on cloud GPUs and OOMs the container.
dataset_num_proc=4,
)
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=train_ds,
args=args,
)
print(f"\n=== SFT warm-up: {len(train_ds)} samples, {SFT_EPOCHS} epoch(s) ===")
trainer.train()
# Save structured log history for plotting.
import json as _json
with open(f"{OUTPUT_DIR}/sft_log.json", "w") as f:
_json.dump(trainer.state.log_history, f, indent=2)
print(f"SFT log saved -> {OUTPUT_DIR}/sft_log.json")
# CRITICAL: save adapter immediately after SFT in case GRPO fails later.
# Without this, an error during GRPO loses the entire SFT adapter.
sft_adapter_path = f"{OUTPUT_DIR}/lora_adapters_sft"
trainer.model.save_pretrained(sft_adapter_path)
tokenizer.save_pretrained(sft_adapter_path)
print(f"SFT-only adapter checkpoint saved -> {sft_adapter_path}")
# Push outputs to Hub now too, so the SFT artifact survives any later crash.
_push_outputs_to_hub("post-SFT checkpoint")
return trainer.model
# ---------------------------------------------------------------------------
# 4. GRPO training
# ---------------------------------------------------------------------------
def run_grpo(model, tokenizer, train_ds, eval_ds):
from trl import GRPOTrainer, GRPOConfig
args = GRPOConfig(
output_dir=f"{OUTPUT_DIR}/grpo",
num_generations=GRPO_NUM_GEN,
max_completion_length=GRPO_MAX_COMPLETION,
per_device_train_batch_size=GRPO_BATCH,
gradient_accumulation_steps=GRPO_GRAD_ACCUM,
learning_rate=GRPO_LR,
max_steps=GRPO_MAX_STEPS,
logging_steps=5,
save_strategy="no",
bf16=USE_BF16,
fp16=USE_FP16,
report_to="wandb" if USE_WANDB else "none",
seed=SEED,
# Keep rollouts fast for Colab:
temperature=0.7,
top_p=0.9,
)
print(f"\n=== GRPO: {len(train_ds)} prompts, max_steps={GRPO_MAX_STEPS}, K={GRPO_NUM_GEN} ===")
trainer = GRPOTrainer(
model=model,
processing_class=tokenizer,
reward_funcs=[
reward_compliance,
reward_appropriateness,
reward_drift_bonus,
],
args=args,
train_dataset=train_ds,
eval_dataset=eval_ds,
)
trainer.train()
# Save structured log history for plotting.
import json as _json
with open(f"{OUTPUT_DIR}/grpo_log.json", "w") as f:
_json.dump(trainer.state.log_history, f, indent=2)
print(f"GRPO log saved -> {OUTPUT_DIR}/grpo_log.json")
return trainer.model
# ---------------------------------------------------------------------------
# 5. Offline eval (drift-sensitive accuracy before vs after)
# ---------------------------------------------------------------------------
def offline_eval(model, tokenizer, eval_ds, label: str, max_rows: int = 200):
"""Greedy-decode each eval prompt, score with total_reward, print summary.
Logs drift-sensitive accuracy broken down by drift direction
(tightening / loosening / neutral). The *tightening* number is the one
that actually matters for the pitch — it measures the leniency bias
that our training specifically aims to remove. Loosening accuracy is
often high even pre-training because the looser rule matches the base
model's internet prior.
"""
from drift_env.training.rewards import parse_generated_action, total_reward
from drift_env.policy import drift_direction
model.eval()
comp_total = appr_total = bonus_total = 0.0
drift_total = drift_correct = 0
per_dir = {"tightening": [0, 0], "loosening": [0, 0], "neutral": [0, 0]} # [correct, total]
n = min(len(eval_ds), max_rows)
for i in range(n):
row = eval_ds[i]
chat = row["prompt"]
inputs = tokenizer.apply_chat_template(
chat, add_generation_prompt=True, return_tensors="pt",
).to(model.device)
with torch.no_grad():
out = model.generate(
inputs,
max_new_tokens=GRPO_MAX_COMPLETION,
do_sample=False,
pad_token_id=tokenizer.eos_token_id,
use_cache=True,
)
text = tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)
r = total_reward(
completion=text,
correct_action_hint=row["correct_action_hint"],
email_kind=row["email_kind"] or None,
can_earn_drift_bonus=row["can_earn_drift_bonus"],
drift_sensitive_to=row["drift_sensitive_to"] or None,
is_admin_email=row["is_admin_email"],
)
comp_total += r["compliance"]
appr_total += r["appropriateness"]
bonus_total += r["drift_bonus"]
# per-direction tracking: a row counts only if it is drift-sensitive
# (i.e. the correct answer is different from what it'd be pre-drift)
drift_to = row["drift_sensitive_to"] or None
if drift_to:
direction = drift_direction(drift_to)
if direction is not None:
per_dir[direction][1] += 1
if r["compliance"] >= 1.0:
per_dir[direction][0] += 1
if row["can_earn_drift_bonus"]:
drift_total += 1
if r["compliance"] >= 1.0:
drift_correct += 1
drift_acc = drift_correct / drift_total if drift_total else None
def _acc(pair):
c, t = pair
return (c / t) if t else None
per_dir_acc = {k: _acc(v) for k, v in per_dir.items()}
print(f"\n=== Offline eval [{label}] over {n} samples ===")
print(f" compliance avg : {comp_total / n:.3f} / 1.0")
print(f" appropriateness avg: {appr_total / n:.3f} / 0.5")
print(f" drift_bonus avg : {bonus_total / n:.3f} / 0.5")
print(f" total avg : {(comp_total + appr_total + bonus_total) / n:.3f} / 2.0")
if drift_acc is not None:
print(f" drift-sens acc : {drift_acc:.1%} ({drift_correct}/{drift_total})")
for direction in ("tightening", "loosening", "neutral"):
c, t = per_dir[direction]
acc_str = f"{c/t:.1%}" if t else "n/a"
print(f" {direction:<11}: {acc_str} ({c}/{t})")
return {
"compliance": comp_total / n,
"appropriateness": appr_total / n,
"drift_bonus": bonus_total / n,
"drift_acc": drift_acc,
"drift_acc_by_direction": per_dir_acc,
"drift_counts_by_direction": {k: {"correct": v[0], "total": v[1]} for k, v in per_dir.items()},
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
seed_all(SEED)
os.makedirs(OUTPUT_DIR, exist_ok=True)
global USE_BF16, USE_FP16
USE_BF16, USE_FP16 = _precision_flags()
print(f"QUICK_MODE={QUICK_MODE} MODEL={MODEL_NAME}")
if torch.cuda.is_available():
name = torch.cuda.get_device_name(0)
cap = torch.cuda.get_device_capability(0)
print(f"GPU: {name} capability={cap} bf16={USE_BF16} fp16={USE_FP16}")
else:
print("CUDA not available — training will be extremely slow")
sft_train, grpo_train, grpo_eval = build_hf_datasets()
model, tokenizer = load_model_and_tokenizer()
# Pre-training offline eval — the "before" number for the pitch clip.
pre = offline_eval(model, tokenizer, grpo_eval, label="pre-training")
# SFT warm-start.
model = run_sft(model, tokenizer, sft_train)
post_sft = offline_eval(model, tokenizer, grpo_eval, label="post-SFT")
# GRPO. If this fails we still want the SFT outputs pushed.
try:
model = run_grpo(model, tokenizer, grpo_train, grpo_eval)
post_grpo = offline_eval(model, tokenizer, grpo_eval, label="post-GRPO")
except Exception as exc:
print(f"\n[grpo] FAILED: {type(exc).__name__}: {exc}")
print("[grpo] Continuing with post-SFT model as the final artifact.")
post_grpo = post_sft # fall back so the summary still prints sensibly
print("\n=== Improvement summary ===")
for k in ("compliance", "appropriateness", "drift_bonus"):
print(f" {k:<20} {pre[k]:.3f} -> {post_sft[k]:.3f} -> {post_grpo[k]:.3f}")
print(f" drift-sens acc (all) {_fmt_acc(pre['drift_acc'])} -> "
f"{_fmt_acc(post_sft['drift_acc'])} -> {_fmt_acc(post_grpo['drift_acc'])}")
for direction in ("tightening", "loosening", "neutral"):
pre_a = pre["drift_acc_by_direction"].get(direction)
sft_a = post_sft["drift_acc_by_direction"].get(direction)
grpo_a = post_grpo["drift_acc_by_direction"].get(direction)
print(f" {direction:<12} {_fmt_acc(pre_a)} -> {_fmt_acc(sft_a)} -> {_fmt_acc(grpo_a)}")
# Save eval snapshots for the plotting script.
import json as _json
with open(f"{OUTPUT_DIR}/evals.json", "w") as f:
_json.dump({
"pre": pre, "post_sft": post_sft, "post_grpo": post_grpo,
"model_name": MODEL_NAME, "quick_mode": QUICK_MODE,
}, f, indent=2, default=str)
print(f"Eval snapshots saved -> {OUTPUT_DIR}/evals.json")
# Save LoRA adapters ONLY (organizer warning: do not naively merge 4-bit).
adapter_path = f"{OUTPUT_DIR}/lora_adapters"
model.save_pretrained(adapter_path)
tokenizer.save_pretrained(adapter_path)
print(f"\nLoRA adapters saved to {adapter_path}")
# Final push of all outputs (SFT + GRPO + plots + adapter).
_push_outputs_to_hub("post-GRPO final")
if not os.getenv("HUB_REPO_ID"):
print("\n[note] HUB_REPO_ID not set; outputs only exist inside this container.")
return 0
def _fmt_acc(a: Optional[float]) -> str:
return f"{a:.1%}" if a is not None else "n/a"
if __name__ == "__main__":
sys.exit(main())