support-ops-env / train.py
raj921
Split train and tool simulator modules; mastery curriculum and grader workflow nudge.
5e75745
Raw
History Blame Contribute Delete
14.1 kB
"""GRPO training entrypoint for DriftShield. Helpers live in ``train_*.py`` modules."""
from __future__ import annotations
import argparse
import csv
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
os.environ.setdefault("TRL_EXPERIMENTAL_SILENCE", "1")
try:
from support_ops_env import SupportOpsEnv
from support_ops_env.tasks import get_curriculum_task_ids
except ImportError:
from client import SupportOpsEnv
from tasks import get_curriculum_task_ids
from train_action_json import parse_tool_calls, to_action
from train_obs_format import (
apply_chat_template,
format_history,
format_observation,
observation_coach_lines,
)
from train_reward_chart import plot_rewards
from train_reward_signal import (
milestone_reward_from_history,
reward_fields,
reward_grounding,
reward_reply,
reward_total,
)
from train_rollout_episode import rollout_once
from train_system_prompt import SYSTEM_PROMPT
from train_tool_bonuses import TOOL_TIERS
from train_vllm_compat import (
_require_vllm_trl_colocate_safe,
patch_trl_vllm_compat,
require_vllm_trl_colocate_safe,
)
logger = logging.getLogger(__name__)
__all__ = [
"SYSTEM_PROMPT",
"TOOL_TIERS",
"apply_chat_template",
"format_history",
"format_observation",
"milestone_reward_from_history",
"observation_coach_lines",
"parse_tool_calls",
"patch_trl_vllm_compat",
"plot_rewards",
"require_vllm_trl_colocate_safe",
"reward_fields",
"reward_grounding",
"reward_reply",
"reward_total",
"rollout_once",
"to_action",
"main",
"parse_args",
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="GRPO training for DriftShield")
parser.add_argument(
"--model-id",
default=None,
help="HF model id (default: Qwen3-1.7B stable, or Qwen3.5-4B with --use-unsloth).",
)
parser.add_argument(
"--use-unsloth",
action="store_true",
help="Unsloth bf16 LoRA (needs pip install -e '.[unsloth]').",
)
parser.add_argument("--env-url", default="http://localhost:8000")
parser.add_argument("--dataset-size", type=int, default=50)
parser.add_argument("--max-turns", type=int, default=15)
parser.add_argument("--num-generations", type=int, default=4)
parser.add_argument("--learning-rate", type=float, default=2e-6)
parser.add_argument("--gradient-accumulation-steps", type=int, default=4)
parser.add_argument("--num-epochs", type=int, default=1)
parser.add_argument("--save-steps", type=int, default=10)
parser.add_argument("--output-dir", default=None)
parser.add_argument("--use-vllm", action="store_true")
parser.add_argument("--vllm-mode", choices=("colocate", "server"), default="colocate")
parser.add_argument("--vllm-gpu-memory-utilization", type=float, default=0.5)
parser.add_argument("--load-in-4bit", action="store_true")
parser.add_argument("--temperature", type=float, default=0.7)
parser.add_argument("--top-p", type=float, default=0.8)
parser.add_argument("--top-k", type=int, default=20)
parser.add_argument("--logging-steps", type=int, default=1)
parser.add_argument("--lora-r", type=int, default=16)
parser.add_argument("--lora-alpha", type=int, default=32)
parser.add_argument("--lora-dropout", type=float, default=0.05)
parser.add_argument("--reward-log", default="reward_log.csv")
parser.add_argument("--difficulty", default="driftshield_easy")
parser.add_argument("--curriculum-state", default=None)
parser.add_argument("--mastery-min-repeat", type=int, default=1)
parser.add_argument("--mastery-max-repeat", type=int, default=4)
parser.add_argument("--mastery-cold-start", type=int, default=3)
parser.add_argument("--dry-run", action="store_true")
return parser.parse_args()
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
args = parse_args()
if args.model_id is None:
args.model_id = "unsloth/Qwen3.5-4B" if args.use_unsloth else "Qwen/Qwen3-1.7B"
from datasets import Dataset
from trl import GRPOConfig, GRPOTrainer
patch_trl_vllm_compat()
loaded_model: Any = None
if args.use_unsloth:
try:
from unsloth import FastLanguageModel # type: ignore
except ImportError as exc:
raise SystemExit("--use-unsloth requires: pip install -e '.[unsloth]'") from exc
logger.info("[unsloth] loading %s", args.model_id)
loaded_model, tokenizer = FastLanguageModel.from_pretrained(
model_name=args.model_id,
max_seq_length=4096,
load_in_4bit=False,
load_in_16bit=True,
full_finetuning=False,
fast_inference=False,
)
loaded_model = FastLanguageModel.get_peft_model(
loaded_model,
r=args.lora_r,
lora_alpha=args.lora_alpha,
lora_dropout=args.lora_dropout,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
bias="none",
use_gradient_checkpointing="unsloth",
random_state=3407,
max_seq_length=4096,
)
else:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(args.model_id)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
env = SupportOpsEnv(base_url=args.env_url).sync()
dataset = Dataset.from_dict(
{"prompt": ["Triage and resolve this support operations case."] * args.dataset_size}
)
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
output_dir = Path(args.output_dir or f"outputs/driftshield-grpo-{ts}")
output_dir.mkdir(parents=True, exist_ok=True)
reward_log = output_dir / args.reward_log
with open(reward_log, "w", newline="") as fh:
csv.writer(fh).writerow([
"episode", "task_id",
"total_reward",
"investigation", "routing", "reply_quality", "groundedness", "submission",
"penalty_total",
"parse_ok_ratio",
"timestamp",
])
curriculum = get_curriculum_task_ids(
args.difficulty,
curriculum_state_path=args.curriculum_state,
mastery_min_repeat=args.mastery_min_repeat,
mastery_max_repeat=args.mastery_max_repeat,
mastery_cold_start_episodes=args.mastery_cold_start,
)
logger.info("curriculum [%s] -> %s", args.difficulty, curriculum)
if args.curriculum_state:
logger.info(
"mastery state=%s min=%s max=%s cold_start=%s",
args.curriculum_state,
args.mastery_min_repeat,
args.mastery_max_repeat,
args.mastery_cold_start,
)
task_cursor = [0]
episode_counter = [0]
def next_task_id() -> str:
tid = curriculum[task_cursor[0] % len(curriculum)]
task_cursor[0] += 1
return tid
diag_every = 5
def log_episode_row(ep: Dict[str, Any]) -> None:
episode_counter[0] += 1
n = episode_counter[0]
parse_ok = float(ep.get("parse_ok_ratio", 0.0))
with open(reward_log, "a", newline="") as fh:
csv.writer(fh).writerow([
n,
ep.get("task_id", ""),
round(ep["total_reward"], 4),
round(ep["investigation_reward"], 4),
round(ep["routing_reward"], 4),
round(ep["reply_reward"], 4),
round(ep["grounding_reward"], 4),
round(ep["submission_reward"], 4),
round(ep["penalty_total"], 4),
round(parse_ok, 4),
datetime.now().isoformat(),
])
if n % diag_every == 0:
failures = int(ep.get("parse_failures", 0))
attempts = int(ep.get("parse_attempts", 0))
err = ep.get("last_parse_error") or "(none)"
raw = (ep.get("last_raw_completion") or "").replace("\n", " ")[:160]
logger.info(
"[diag ep=%d] parse_ok=%.2f (%d/%d) | total=%+.2f inv=%.2f route=%.2f reply=%.2f "
"ground=%.2f sub=%.2f | last_err=%s | last_raw[:160]=%r",
n, parse_ok, attempts - failures, attempts,
float(ep.get("total_reward", 0.0)),
float(ep.get("investigation_reward", 0.0)),
float(ep.get("routing_reward", 0.0)),
float(ep.get("reply_reward", 0.0)),
float(ep.get("grounding_reward", 0.0)),
float(ep.get("submission_reward", 0.0)),
err, raw,
)
if parse_ok < 0.5:
logger.warning(
"[diag ep=%d] parse_ok_ratio<0.5 — check prompt, max_completion_length, base model",
n,
)
elif (
parse_ok >= 0.9
and float(ep.get("reply_reward", 0.0)) == 0.0
and float(ep.get("grounding_reward", 0.0)) == 0.0
and float(ep.get("submission_reward", 0.0)) == 0.0
):
logger.warning("[diag ep=%d] parses OK but stalls before reply/submit", n)
def rollout_func(prompts: List[str], trainer: Any) -> Dict[str, List[Any]]:
out: Dict[str, List[Any]] = {
"prompt_ids": [], "completion_ids": [], "logprobs": [],
"total_reward": [], "field_reward": [], "reply_reward": [], "grounding_reward": [],
}
if hasattr(trainer, "vllm_generation") and trainer.vllm_generation is not None:
current_step = int(getattr(getattr(trainer, "state", None), "global_step", -1))
if getattr(trainer, "_driftshield_vllm_synced_step", None) != current_step:
trainer.vllm_generation.sync_weights()
trainer._driftshield_vllm_synced_step = current_step
for _ in prompts:
ep = rollout_once(
trainer, env, tokenizer, SYSTEM_PROMPT, args.max_turns,
task_id=next_task_id(),
temperature=args.temperature,
top_p=args.top_p,
top_k=args.top_k,
)
log_episode_row(ep)
for key in out:
value = ep.get(key)
if value is None:
raise KeyError(
f"rollout_once() missing key {key!r}. Available: {sorted(ep.keys())}"
)
out[key].append(value)
return out
grpo_kwargs: Dict[str, Any] = dict(
output_dir=str(output_dir),
num_train_epochs=args.num_epochs,
learning_rate=args.learning_rate,
gradient_accumulation_steps=args.gradient_accumulation_steps,
per_device_train_batch_size=args.num_generations,
num_generations=args.num_generations,
max_completion_length=768,
logging_steps=args.logging_steps,
save_strategy="steps",
save_steps=args.save_steps,
temperature=args.temperature,
top_p=args.top_p,
top_k=args.top_k,
report_to="none",
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
save_total_limit=3,
)
if args.use_vllm and not args.use_unsloth:
_require_vllm_trl_colocate_safe()
grpo_kwargs.update(
use_vllm=True,
vllm_mode=args.vllm_mode,
vllm_gpu_memory_utilization=args.vllm_gpu_memory_utilization,
)
elif args.use_vllm and args.use_unsloth:
logger.warning("--use-vllm ignored in Unsloth mode")
if args.load_in_4bit and not args.use_unsloth:
import torch
from transformers import BitsAndBytesConfig
compute_dtype = (
torch.bfloat16
if torch.cuda.is_available() and torch.cuda.is_bf16_supported()
else torch.float16
)
grpo_kwargs["model_init_kwargs"] = {
"torch_dtype": compute_dtype,
"quantization_config": BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=compute_dtype,
bnb_4bit_use_double_quant=True,
),
}
elif not args.use_unsloth:
import torch
grpo_kwargs["model_init_kwargs"] = {"torch_dtype": torch.bfloat16}
elif args.load_in_4bit and args.use_unsloth:
logger.warning("--load-in-4bit ignored in Unsloth mode")
grpo_config = GRPOConfig(**grpo_kwargs)
if args.use_unsloth:
model_arg: Any = loaded_model
peft_config: Optional[Any] = None
else:
from peft import LoraConfig
model_arg = args.model_id
peft_config = LoraConfig(
r=args.lora_r, lora_alpha=args.lora_alpha, lora_dropout=args.lora_dropout,
bias="none", task_type="CAUSAL_LM",
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
)
trainer = GRPOTrainer(
model=model_arg,
processing_class=tokenizer,
reward_funcs=[reward_total, reward_fields, reward_reply, reward_grounding],
train_dataset=dataset,
args=grpo_config,
rollout_func=rollout_func,
peft_config=peft_config,
)
if args.dry_run:
logger.info("--dry-run: skipping trainer.train()")
return
try:
trainer.train()
finally:
env.close() if hasattr(env, "close") else None
trainer.save_model(str(output_dir))
plot_rewards(reward_log, output_dir / "reward_curve.png")
logger.info("done — %s", output_dir)
if __name__ == "__main__":
main()