Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """Component 6 β Training Script (Colab-Ready, GRPO + Unsloth). | |
| Trains the detective agent using GRPO (Group Relative Policy Optimisation) | |
| while other agents use fixed prompt-based LLM calls. Supports curriculum | |
| difficulty staging per Β§6 of the hackathon guide. | |
| """ | |
| # Unsloth is imported at the start of load_models() so its class-level patches | |
| # are applied before any model is instantiated. Both detective and NPC are | |
| # loaded via FastLanguageModel, ensuring Qwen2RotaryEmbedding instances get | |
| # extend_rope_embedding set at construction time (required by Unsloth β₯2026.1). | |
| # lora_dropout=0.0 and use_gradient_checkpointing=False are required to avoid | |
| # in-place buffer conflicts with manual per-completion .backward() in GRPO. | |
| USE_UNSLOTH = False # updated inside load_models() if package is available | |
| import copy | |
| import json | |
| import os | |
| import sys | |
| import time | |
| import warnings | |
| from typing import Optional | |
| import random | |
| import matplotlib.pyplot as plt | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| from torch.optim import AdamW | |
| from transformers import ( | |
| AutoConfig, | |
| AutoModelForCausalLM, | |
| AutoTokenizer, | |
| BitsAndBytesConfig, | |
| pipeline, | |
| ) | |
| from peft import LoraConfig, TaskType, get_peft_model | |
| # Unsloth (optional β 2x faster training on supported GPUs) | |
| # Add project root to path | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from crime_env.environment import CrimeInvestigationEnv | |
| from crime_env.case_generator import generate_case | |
| # ββ Configuration βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _env_bool(name: str, default: bool = False) -> bool: | |
| value = os.environ.get(name) | |
| if value is None: | |
| return default | |
| return value.strip().lower() in {"1", "true", "yes", "on"} | |
| def _env_int(name: str, default: int) -> int: | |
| value = os.environ.get(name) | |
| if value is None: | |
| return default | |
| try: | |
| return int(value) | |
| except ValueError: | |
| return default | |
| # Default profile tuned for 6GB VRAM reliability. | |
| TEST_MODE = _env_bool("TEST_MODE", False) | |
| MODEL_NAME = os.environ.get("MODEL_NAME", "Qwen/Qwen2.5-1.5B-Instruct") | |
| NPC_MODEL_NAME = os.environ.get("NPC_MODEL_NAME", "Qwen/Qwen2.5-0.5B-Instruct") | |
| NUM_EPISODES = _env_int("NUM_EPISODES", 5 if TEST_MODE else 300) | |
| MAX_TURNS = _env_int("MAX_TURNS", 8 if TEST_MODE else 15) | |
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" | |
| REWARDS_FILE = "rewards.json" | |
| TRANSCRIPTS_FILE = "episode_transcripts.json" | |
| NPC_MAX_NEW_TOKENS = _env_int("NPC_MAX_NEW_TOKENS", 48 if TEST_MODE else 80) | |
| DETECTIVE_MAX_NEW_TOKENS = _env_int( | |
| "DETECTIVE_MAX_NEW_TOKENS", 40 if TEST_MODE else 56 | |
| ) | |
| DETECTIVE_RETRY_MAX_NEW_TOKENS = _env_int( | |
| "DETECTIVE_RETRY_MAX_NEW_TOKENS", 24 if TEST_MODE else 40 | |
| ) | |
| TRANSCRIPT_EPISODES = { | |
| int(x.strip()) | |
| for x in os.environ.get("TRANSCRIPT_EPISODES", "1,25,50").split(",") | |
| if x.strip().isdigit() | |
| } | |
| STRICT_FORMAT_FALLBACK_THRESHOLD = float( | |
| os.environ.get("STRICT_FORMAT_FALLBACK_THRESHOLD", "0.35") | |
| ) | |
| STRICT_FORMAT_WINDOW = _env_int("STRICT_FORMAT_WINDOW", 5) | |
| # GRPO hyperparameters | |
| NUM_GENERATIONS = _env_int("NUM_GENERATIONS", 1 if TEST_MODE else 4) | |
| GRPO_BETA = float(os.environ.get("GRPO_BETA", "0.04")) # KL coefficient | |
| GRPO_LR = float(os.environ.get("GRPO_LR", "1e-5")) # learning rate | |
| GRPO_MAX_GRAD_NORM = 0.5 | |
| # Curriculum thresholds (Β§6) | |
| CURRICULUM_ADVANCE_RATE = 0.6 # rolling accuracy required to advance tier | |
| CURRICULUM_WINDOW = 10 # episodes over which accuracy is averaged | |
| def _format_chat_prompt( | |
| tokenizer, | |
| system_prompt: str, | |
| user_prompt: str, | |
| ) -> str: | |
| """Format prompts using the tokenizer chat template when available.""" | |
| messages = [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| if hasattr(tokenizer, "apply_chat_template"): | |
| return tokenizer.apply_chat_template( | |
| messages, | |
| tokenize=False, | |
| add_generation_prompt=True, | |
| ) | |
| formatted = f"System: {system_prompt}\n\nUser: {user_prompt}\n\nAssistant:" | |
| return formatted | |
| # ββ Model Loading βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _load_detective_standard(quant_config, use_quantization: bool): | |
| """Load detective model via standard transformers + PEFT (LoRA).""" | |
| model_dtype = torch.float16 if DEVICE == "cuda" else torch.float32 | |
| lora_config = LoraConfig( | |
| r=8, | |
| lora_alpha=16, | |
| target_modules=["q_proj", "v_proj"], | |
| lora_dropout=0.05, | |
| bias="none", | |
| task_type=TaskType.CAUSAL_LM, | |
| ) if DEVICE == "cuda" else None | |
| load_kwargs = { | |
| "quantization_config": quant_config, | |
| "torch_dtype": model_dtype, | |
| "device_map": "auto" if DEVICE == "cuda" else None, | |
| "trust_remote_code": True, | |
| } | |
| try: | |
| base_model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, **load_kwargs) | |
| except ValueError as e: | |
| error_text = str(e) | |
| if DEVICE == "cuda" and "dispatched on the CPU or the disk" in error_text: | |
| print("Low VRAM detected, retrying with CPU offload...") | |
| offload_quant = BitsAndBytesConfig( | |
| load_in_4bit=True, | |
| bnb_4bit_compute_dtype=torch.float16, | |
| bnb_4bit_use_double_quant=True, | |
| bnb_4bit_quant_type="nf4", | |
| llm_int8_enable_fp32_cpu_offload=True, | |
| ) | |
| load_kwargs["quantization_config"] = offload_quant | |
| base_model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, **load_kwargs) | |
| else: | |
| raise | |
| if lora_config is not None: | |
| try: | |
| detective_model = get_peft_model(base_model, lora_config) | |
| print("Detective model loaded via transformers + LoRA.") | |
| except ValueError as e: | |
| if "Target modules" in str(e) and "not found" in str(e): | |
| print("LoRA target modules not found, loading without LoRA.") | |
| detective_model = base_model | |
| else: | |
| raise | |
| else: | |
| detective_model = base_model | |
| return detective_model | |
| def load_models(): | |
| """Load the detective (trainable), reference (frozen), and NPC (frozen) models. | |
| Returns: | |
| (detective_model, ref_model, npc_pipeline, tokenizer, npc_tokenizer) | |
| - detective_model: FastLanguageModel + LoRA adapters (or standard PEFT fallback) | |
| - ref_model: Frozen base model on CPU for GRPO KL penalty (None when Unsloth active) | |
| - npc_pipeline: text-generation pipeline for NPC agents | |
| - tokenizer: detective tokenizer | |
| - npc_tokenizer: NPC tokenizer | |
| """ | |
| global USE_UNSLOTH | |
| # ββ Import Unsloth first so patches are applied before any instantiation β | |
| # Using FastLanguageModel for the detective ensures Qwen2RotaryEmbedding | |
| # instances get extend_rope_embedding set at construction time, which | |
| # Unsloth β₯2026.1 requires in its patched LlamaModel_fast_forward. | |
| _FastLanguageModel = None | |
| if DEVICE == "cuda": | |
| try: | |
| from unsloth import FastLanguageModel as _FastLanguageModel | |
| USE_UNSLOTH = True | |
| print("Unsloth: enabled") | |
| except ImportError: | |
| print("Unsloth: not available, using standard transformers") | |
| print(f"Loading model: {MODEL_NAME}") | |
| print(f"Device: {DEVICE}") | |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True) | |
| if tokenizer.pad_token is None: | |
| tokenizer.pad_token = tokenizer.eos_token | |
| npc_tokenizer = AutoTokenizer.from_pretrained( | |
| NPC_MODEL_NAME, trust_remote_code=True, | |
| ) | |
| if npc_tokenizer.pad_token is None: | |
| npc_tokenizer.pad_token = npc_tokenizer.eos_token | |
| model_config = AutoConfig.from_pretrained(MODEL_NAME, trust_remote_code=True) | |
| hidden_size = getattr(model_config, "hidden_size", None) | |
| use_quantization = DEVICE == "cuda" and (hidden_size is None or hidden_size >= 64) | |
| quant_config = None | |
| if use_quantization: | |
| try: | |
| quant_config = BitsAndBytesConfig( | |
| load_in_4bit=True, | |
| bnb_4bit_compute_dtype=torch.float16, | |
| bnb_4bit_use_double_quant=True, | |
| bnb_4bit_quant_type="nf4", | |
| ) | |
| except Exception: | |
| print("BitsAndBytes not available, loading without quantisation") | |
| elif DEVICE == "cuda": | |
| print("Skipping 4-bit quantisation for small hidden-size model") | |
| # ββ Detective model (trainable) ββββββββββββββββββββββββββββββββββββββ | |
| # Load via FastLanguageModel when Unsloth is available so rotary_emb | |
| # instances get extend_rope_embedding correctly initialised. | |
| # lora_dropout=0.0 is required for Unsloth's fast kernel path. | |
| # use_gradient_checkpointing=False avoids in-place buffer reuse that | |
| # breaks per-completion .backward() calls in grpo_update. | |
| if _FastLanguageModel is not None: | |
| try: | |
| detective_model, tokenizer = _FastLanguageModel.from_pretrained( | |
| model_name=MODEL_NAME, | |
| max_seq_length=1024, | |
| dtype=torch.float16, | |
| load_in_4bit=use_quantization, | |
| trust_remote_code=True, | |
| ) | |
| if tokenizer.pad_token is None: | |
| tokenizer.pad_token = tokenizer.eos_token | |
| detective_model = _FastLanguageModel.get_peft_model( | |
| detective_model, | |
| r=8, | |
| target_modules=["q_proj", "v_proj"], | |
| lora_alpha=16, | |
| lora_dropout=0.0, | |
| bias="none", | |
| use_gradient_checkpointing=False, | |
| random_state=42, | |
| ) | |
| print("Detective model loaded via Unsloth + LoRA.") | |
| except Exception as e: | |
| print(f"Unsloth detective load failed ({e}), falling back to transformers.") | |
| detective_model = _load_detective_standard(quant_config, use_quantization) | |
| else: | |
| detective_model = _load_detective_standard(quant_config, use_quantization) | |
| # ββ Reference model (frozen, CPU) β used for GRPO KL penalty ββββββββ | |
| # Skipped when Unsloth is active: Unsloth's Triton kernels require CUDA | |
| # tensors and crash on CPU-resident models. grpo_update handles | |
| # ref_model=None gracefully (KL term is omitted). | |
| ref_model = None | |
| if TEST_MODE: | |
| print("TEST_MODE: skipping reference model load (KL penalty disabled).") | |
| elif USE_UNSLOTH: | |
| print("Unsloth active: skipping reference model (KL penalty disabled to avoid Triton/CPU clash).") | |
| else: | |
| print("Loading reference model on CPU (for GRPO KL penalty) ...") | |
| try: | |
| ref_model = AutoModelForCausalLM.from_pretrained( | |
| MODEL_NAME, | |
| torch_dtype=torch.float16, | |
| device_map="cpu", | |
| trust_remote_code=True, | |
| ) | |
| ref_model.eval() | |
| for param in ref_model.parameters(): | |
| param.requires_grad = False | |
| print("Reference model loaded on CPU.") | |
| except Exception as e: | |
| print(f"Warning: reference model failed to load ({e}). KL penalty will be skipped.") | |
| # ββ NPC pipeline β separate frozen copy βββββββββββββββββββββββββββββ | |
| npc_base_model = None | |
| try: | |
| if _FastLanguageModel is not None: | |
| npc_base_model, _npc_tok = _FastLanguageModel.from_pretrained( | |
| model_name=NPC_MODEL_NAME, | |
| max_seq_length=512, | |
| dtype=torch.float16, | |
| load_in_4bit=use_quantization, | |
| trust_remote_code=True, | |
| ) | |
| else: | |
| npc_base_model = AutoModelForCausalLM.from_pretrained( | |
| NPC_MODEL_NAME, | |
| quantization_config=quant_config, | |
| torch_dtype=torch.float16 if DEVICE == "cuda" else torch.float32, | |
| device_map="auto" if DEVICE == "cuda" else None, | |
| trust_remote_code=True, | |
| ) | |
| npc_base_model.eval() | |
| for param in npc_base_model.parameters(): | |
| param.requires_grad = False | |
| except Exception as e: | |
| raise RuntimeError(f"NPC model failed to load: {e}") from e | |
| npc_pipeline = pipeline( | |
| "text-generation", | |
| model=npc_base_model, | |
| tokenizer=npc_tokenizer, | |
| max_new_tokens=NPC_MAX_NEW_TOKENS, | |
| do_sample=True, | |
| temperature=0.7, | |
| top_p=0.9, | |
| ) | |
| return detective_model, ref_model, npc_pipeline, tokenizer, npc_tokenizer | |
| # ββ NPC LLM Call ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def make_npc_call(npc_pipeline, npc_tokenizer): | |
| """Create a callable for NPC agent responses.""" | |
| def llm_call(system_prompt: str, conversation_history: list[dict]) -> str: | |
| # Build a bounded user prompt to avoid model context overflows. | |
| user_prompt = "Conversation so far:\n" | |
| # Keep only recent turns and constrain prompt length. | |
| recent_history = conversation_history[-10:] | |
| for entry in recent_history: | |
| speaker = entry.get("speaker", "Unknown") | |
| content = entry.get("content", "") | |
| user_prompt += f"{speaker}: {content[:180]}\n" | |
| user_prompt = user_prompt[-1800:] | |
| prompt_text = _format_chat_prompt( | |
| npc_tokenizer, | |
| system_prompt[:800], | |
| user_prompt + "\nRespond in 1-2 short sentences in character. No action format.", | |
| ) | |
| try: | |
| result = npc_pipeline(prompt_text, return_full_text=False) | |
| response = result[0]["generated_text"].strip() | |
| # Clean up: take first sentence/paragraph | |
| if "\n" in response: | |
| response = response.split("\n")[0] | |
| return response[:300] if response else "I have nothing to add." | |
| except Exception as e: | |
| print(f" NPC call error: {e}") | |
| return "I don't recall anything specific about that." | |
| return llm_call | |
| # ββ Detective Action Generation βββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate_detective_action( | |
| detective_model, | |
| tokenizer, | |
| observation: dict, | |
| strict_action_format: bool = False, | |
| episode_max_turns: int = MAX_TURNS, | |
| ) -> tuple[str, torch.Tensor, torch.Tensor, bool]: | |
| """Generate a detective action using the trainable model. | |
| Returns: | |
| (action_string, query_tensor, response_tensor, used_fallback) | |
| """ | |
| # Force-accuse at 70 % of episode length so it scales with difficulty tier. | |
| force_accuse_at = max(1, int(episode_max_turns * 0.7)) | |
| # Build prompt from observation | |
| prompt = f"""You are a detective. Based on the conversation so far, choose your next action. | |
| Briefing: {observation['briefing'][:300]} | |
| Turn: {observation['turn']}/{episode_max_turns} | |
| Recent conversation: | |
| """ | |
| history = observation.get("conversation_history", []) | |
| for entry in history[-6:]: | |
| prompt += f" {entry['speaker']}: {entry['content'][:100]}\n" | |
| prompt += """ | |
| Choose ONE action using EXACTLY this format: | |
| ACTION: ask_question | TARGET: Suspect_A | CONTENT: <question> | |
| ACTION: request_evidence | ITEM: keycard_log | |
| ACTION: accuse | TARGET: Suspect_A | |
| Your action: | |
| """ | |
| turn_now = observation.get("turn", 0) | |
| if turn_now >= force_accuse_at: | |
| prompt += ( | |
| f"\nWARNING: Turn {turn_now}/{episode_max_turns}. " | |
| "You have gathered enough information. You MUST accuse a suspect NOW. " | |
| "Use: ACTION: accuse | TARGET: Suspect_A or ACTION: accuse | TARGET: Suspect_B\n" | |
| ) | |
| if strict_action_format: | |
| prompt += ( | |
| "\nIMPORTANT: Output ONLY a single valid ACTION line. " | |
| "No explanations, no extra text.\n" | |
| ) | |
| formatted_prompt = _format_chat_prompt( | |
| tokenizer, | |
| "You are a detective. Choose your next action.", | |
| prompt, | |
| ) | |
| # Tokenize | |
| inputs = tokenizer( | |
| formatted_prompt, | |
| return_tensors="pt", | |
| truncation=True, | |
| max_length=1024, | |
| ) | |
| query_tensor = inputs["input_ids"].squeeze().detach().cpu() | |
| if DEVICE == "cuda": | |
| inputs = {k: v.to(DEVICE) for k, v in inputs.items()} | |
| # Generate | |
| with torch.no_grad(): | |
| output = detective_model.generate( | |
| **inputs, | |
| max_new_tokens=DETECTIVE_MAX_NEW_TOKENS, | |
| do_sample=True, | |
| temperature=0.4 if strict_action_format else 0.8, | |
| top_p=0.8 if strict_action_format else 0.9, | |
| pad_token_id=tokenizer.pad_token_id, | |
| ) | |
| response_tensor = output.squeeze()[len(query_tensor):].detach().cpu() | |
| action_text = tokenizer.decode(response_tensor, skip_special_tokens=True).strip() | |
| def _is_valid_action(text: str) -> bool: | |
| normalized = text.strip().upper() | |
| return normalized.startswith("ACTION:") | |
| # Retry once in strict mode before falling back to scripted actions. | |
| if not _is_valid_action(action_text): | |
| strict_prompt = prompt + ( | |
| "\nFINAL REMINDER: Reply with one line starting with 'ACTION:' only.\n" | |
| ) | |
| strict_formatted_prompt = _format_chat_prompt( | |
| tokenizer, | |
| "You are a detective. Choose your next action.", | |
| strict_prompt, | |
| ) | |
| strict_inputs = tokenizer( | |
| strict_formatted_prompt, | |
| return_tensors="pt", | |
| truncation=True, | |
| max_length=1024, | |
| ) | |
| if DEVICE == "cuda": | |
| strict_inputs = {k: v.to(DEVICE) for k, v in strict_inputs.items()} | |
| with torch.no_grad(): | |
| retry_output = detective_model.generate( | |
| **strict_inputs, | |
| max_new_tokens=DETECTIVE_RETRY_MAX_NEW_TOKENS, | |
| do_sample=True, | |
| temperature=0.25, | |
| top_p=0.75, | |
| pad_token_id=tokenizer.pad_token_id, | |
| ) | |
| strict_query_tensor = strict_inputs["input_ids"].squeeze().detach().cpu() | |
| retry_response_tensor = retry_output.squeeze()[len(strict_query_tensor):].detach().cpu() | |
| retry_action_text = tokenizer.decode( | |
| retry_response_tensor, skip_special_tokens=True | |
| ).strip() | |
| if _is_valid_action(retry_action_text): | |
| query_tensor = strict_query_tensor | |
| response_tensor = retry_response_tensor | |
| action_text = retry_action_text | |
| used_fallback = False | |
| # If model output doesn't parse, generate a deterministic fallback action. | |
| if not _is_valid_action(action_text): | |
| used_fallback = True | |
| turn = observation.get("turn", 0) | |
| if turn < 4: | |
| action_text = "ACTION: ask_question | TARGET: Suspect_A | CONTENT: Where were you at the time of the crime?" | |
| elif turn < 8: | |
| action_text = "ACTION: ask_question | TARGET: Suspect_B | CONTENT: Can you describe your alibi?" | |
| elif turn < 10: | |
| action_text = "ACTION: request_evidence | ITEM: keycard_log" | |
| else: | |
| # Late turns: accuse with a randomized suspect to avoid systemic bias. | |
| fallback_target = random.choice(["Suspect_A", "Suspect_B"]) | |
| action_text = f"ACTION: accuse | TARGET: {fallback_target}" | |
| # Never train on scripted fallback actions (they are off-policy). | |
| if used_fallback: | |
| response_tensor = tokenizer( | |
| action_text, | |
| return_tensors="pt", | |
| add_special_tokens=False, | |
| )["input_ids"].squeeze() | |
| return action_text, query_tensor, response_tensor, used_fallback | |
| # ββ GRPO Core ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def score_candidates( | |
| env: CrimeInvestigationEnv, | |
| actions: list[str], | |
| ) -> list[float]: | |
| """Score each candidate action by stepping isolated env copies. | |
| Returns the *incremental* reward delta for each action (not cumulative), | |
| so advantages reflect only what each candidate action contributes at this | |
| step β not the accumulated reward from previous turns. | |
| """ | |
| llm_call_ref = env.llm_call # callable; restore after deepcopy | |
| base_reward = float(env.reward_calc.get_rewards()["detective"]) | |
| rewards: list[float] = [] | |
| for action in actions: | |
| try: | |
| env_copy = copy.deepcopy(env) | |
| env_copy.llm_call = llm_call_ref | |
| env_copy.step(action) | |
| delta = float(env_copy.reward_calc.get_rewards()["detective"]) - base_reward | |
| except Exception as exc: | |
| print(f" score_candidates error: {exc}") | |
| delta = 0.0 | |
| rewards.append(delta) | |
| return rewards | |
| def grpo_update( | |
| detective_model, | |
| ref_model, | |
| optimizer: AdamW, | |
| query_tensors: list, | |
| response_tensors: list, | |
| rewards: list[float], | |
| beta: float = GRPO_BETA, | |
| max_grad_norm: float = GRPO_MAX_GRAD_NORM, | |
| ) -> dict: | |
| """One GRPO gradient step over a group of G completions. | |
| Group-relative advantage: A_i = (r_i β ΞΌ_r) / (Ο_r + Ξ΅) | |
| Per-completion loss: βA_i Β· mean_log_prob(response) + Ξ² Β· KL(Ο β₯ Ο_ref) | |
| Final loss: mean over the group. | |
| Args: | |
| detective_model: Trainable policy model. | |
| ref_model: Frozen reference model on CPU (None β skip KL). | |
| optimizer: AdamW for detective_model. | |
| query_tensors: List of G query tensors (CPU int64). | |
| response_tensors: List of G response tensors (CPU int64). | |
| rewards: List of G float rewards. | |
| beta: KL coefficient. | |
| max_grad_norm: Gradient clipping threshold. | |
| Returns: | |
| Dict with keys "loss" and "kl". | |
| """ | |
| G = len(rewards) | |
| if G < 2: | |
| # Need β₯ 2 completions for a non-trivial group-relative advantage. | |
| return {"loss": 0.0, "kl": 0.0, "skipped": True} | |
| mean_r = float(np.mean(rewards)) | |
| std_r = float(np.std(rewards)) + 1e-8 | |
| advantages = [(float(r) - mean_r) / std_r for r in rewards] | |
| losses: list[torch.Tensor] = [] | |
| kl_vals: list[float] = [] | |
| # Zero grad once before all forward passes so gradient accumulation is | |
| # correct and Unsloth's in-place gradient-checkpointing ops don't corrupt | |
| # tensors that are still live in the computation graph. | |
| optimizer.zero_grad() | |
| for q_cpu, r_cpu, adv in zip(query_tensors, response_tensors, advantages): | |
| if r_cpu is None or r_cpu.numel() == 0: | |
| continue | |
| q_len = q_cpu.numel() | |
| r_len = r_cpu.numel() | |
| # Full sequence on compute device | |
| input_ids = torch.cat([q_cpu.long(), r_cpu.long()]).unsqueeze(0).to(DEVICE) | |
| # ββ Policy forward (with grad) ββββββββββββββββββββββββββββββββ | |
| logits = detective_model(input_ids).logits # [1, seq, vocab] | |
| resp_logits = logits[0, q_len - 1 : q_len + r_len - 1, :] # [r_len, vocab] | |
| resp_ids = input_ids[0, q_len:] # [r_len] | |
| log_probs = F.log_softmax(resp_logits, dim=-1) # has grad | |
| token_lp = log_probs.gather(1, resp_ids.unsqueeze(1)).squeeze(1) | |
| mean_log_prob = token_lp.mean() # scalar, has grad | |
| # ββ Reference forward (no grad, CPU) βββββββββββββββββββββββββ | |
| kl_loss = torch.tensor(0.0, device=DEVICE) | |
| if ref_model is not None: | |
| with torch.no_grad(): | |
| ref_out = ref_model(input_ids.cpu()) | |
| ref_resp_logits = ref_out.logits[0, q_len - 1 : q_len + r_len - 1, :] | |
| ref_log_probs = F.log_softmax(ref_resp_logits, dim=-1) # CPU, no grad | |
| ref_log_probs_dev = ref_log_probs.to(DEVICE) | |
| # KL(Ο β₯ Ο_ref): E_v[Ο(v) Β· (log Ο(v) β log Ο_ref(v))] | |
| pi_detached = log_probs.detach().exp() | |
| kl_per_token = (pi_detached * (log_probs - ref_log_probs_dev)).sum(dim=-1) | |
| kl_loss = kl_per_token.mean() | |
| kl_vals.append(kl_loss.item()) | |
| # ββ GRPO step loss β backward immediately to avoid stale graph β | |
| step_loss = -adv * mean_log_prob + beta * kl_loss | |
| # Divide by G so the accumulated gradient equals the group mean. | |
| (step_loss / G).backward() | |
| losses.append(step_loss.detach()) | |
| if not losses: | |
| return {"loss": 0.0, "kl": 0.0, "skipped": True} | |
| total_loss_val = float(torch.stack(losses).mean().item()) | |
| torch.nn.utils.clip_grad_norm_( | |
| [p for p in detective_model.parameters() if p.requires_grad], | |
| max_grad_norm, | |
| ) | |
| optimizer.step() | |
| if DEVICE == "cuda": | |
| torch.cuda.empty_cache() | |
| return { | |
| "loss": total_loss_val, | |
| "kl": float(np.mean(kl_vals)) if kl_vals else 0.0, | |
| } | |
| # ββ Training Loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def train(): | |
| """Main GRPO training loop with curriculum difficulty staging.""" | |
| print("=" * 60) | |
| print(" AI Crime Investigation World β GRPO Training") | |
| print("=" * 60) | |
| if TEST_MODE: | |
| print(" Running in TEST_MODE (smoke-test settings enabled)") | |
| print( | |
| f" MODEL_NAME={MODEL_NAME} | NPC_MODEL_NAME={NPC_MODEL_NAME} | " | |
| f"NUM_EPISODES={NUM_EPISODES} | MAX_TURNS={MAX_TURNS}" | |
| ) | |
| print( | |
| f" GRPO: num_generations={NUM_GENERATIONS} | beta={GRPO_BETA} | lr={GRPO_LR}" | |
| ) | |
| print("=" * 60) | |
| warnings.filterwarnings("ignore", message="No dataset is provided.*", category=UserWarning) | |
| warnings.filterwarnings("ignore", message=r"std\(\).*degrees of freedom", category=UserWarning) | |
| warnings.filterwarnings("ignore", message=".*pipelines sequentially.*", category=UserWarning) | |
| # Load models | |
| detective_model, ref_model, npc_pipeline, tokenizer, npc_tokenizer = load_models() | |
| # Optimizer β only trains LoRA parameters | |
| trainable_params = [p for p in detective_model.parameters() if p.requires_grad] | |
| optimizer = AdamW(trainable_params, lr=GRPO_LR) | |
| # Environment | |
| npc_call = make_npc_call(npc_pipeline, npc_tokenizer) | |
| env = CrimeInvestigationEnv(llm_call=npc_call) | |
| # Tracking | |
| reward_log: list[float] = [] | |
| results_log: list[str] = [] | |
| transcript_log: list[dict] = [] | |
| difficulty_log: list[str] = [] | |
| # Curriculum state | |
| correct_history: list[int] = [] # 1 = correct accusation, 0 = otherwise | |
| print(f"\nStarting training for {NUM_EPISODES} episodes...\n") | |
| strict_action_format = False | |
| fallback_rate_history: list[float] = [] | |
| def _get_difficulty(episode: int) -> str: | |
| """Pick difficulty tier with time-based floor + rolling-accuracy auto-advance.""" | |
| floor = "easy" if episode < 50 else ("medium" if episode < 150 else "hard") | |
| if len(correct_history) >= CURRICULUM_WINDOW: | |
| rolling = sum(correct_history[-CURRICULUM_WINDOW:]) / CURRICULUM_WINDOW | |
| if rolling >= CURRICULUM_ADVANCE_RATE: | |
| if floor == "easy": | |
| return "medium" | |
| if floor == "medium": | |
| return "hard" | |
| return floor | |
| def save_training_artifacts(checkpoint_dir: Optional[str] = None) -> None: | |
| with open(REWARDS_FILE, "w") as f: | |
| json.dump( | |
| { | |
| "rewards": reward_log, | |
| "results": results_log, | |
| "difficulty": difficulty_log, | |
| "num_episodes": NUM_EPISODES, | |
| "model": MODEL_NAME, | |
| }, | |
| f, indent=2, | |
| ) | |
| with open(TRANSCRIPTS_FILE, "w") as f: | |
| json.dump( | |
| { | |
| "episodes_captured": sorted([t["episode"] for t in transcript_log]), | |
| "transcripts": transcript_log, | |
| }, | |
| f, indent=2, | |
| ) | |
| _plot_reward_curve(reward_log) | |
| if checkpoint_dir is not None: | |
| os.makedirs(checkpoint_dir, exist_ok=True) | |
| detective_model.save_pretrained(checkpoint_dir) | |
| tokenizer.save_pretrained(checkpoint_dir) | |
| for episode in range(NUM_EPISODES): | |
| t0 = time.time() | |
| # ββ Curriculum: pick difficulty and generate matching case ββββ | |
| difficulty = _get_difficulty(episode) | |
| difficulty_log.append(difficulty) | |
| case = generate_case(difficulty=difficulty) | |
| episode_turns = case.get("max_turns", MAX_TURNS) | |
| env.MAX_TURNS = episode_turns | |
| obs = env.reset(case_data=case) | |
| done = False | |
| fallback_steps = 0 | |
| total_steps = 0 | |
| grpo_updates = 0 | |
| total_loss_sum = 0.0 | |
| while not done: | |
| # ββ Generate G candidate actions ββββββββββββββββββββββββββ | |
| candidates: list[tuple[str, torch.Tensor, torch.Tensor, bool]] = [] | |
| for _ in range(NUM_GENERATIONS): | |
| action, q_t, r_t, used_fb = generate_detective_action( | |
| detective_model, tokenizer, obs, | |
| strict_action_format=strict_action_format, | |
| episode_max_turns=episode_turns, | |
| ) | |
| candidates.append((action, q_t, r_t, used_fb)) | |
| # ββ Score all candidates via env copies (reward_fn) βββββββ | |
| group_rewards = score_candidates(env, [c[0] for c in candidates]) | |
| # ββ GRPO update on non-fallback candidates ββββββββββββββββ | |
| valid = [(c, r) for c, r in zip(candidates, group_rewards) if not c[3]] | |
| if len(valid) >= 2: | |
| stats = grpo_update( | |
| detective_model, ref_model, optimizer, | |
| [c[1] for c, _ in valid], | |
| [c[2] for c, _ in valid], | |
| [r for _, r in valid], | |
| ) | |
| if not stats.get("skipped"): | |
| grpo_updates += 1 | |
| total_loss_sum += stats.get("loss", 0.0) | |
| # ββ Advance main env with highest-reward action βββββββββββ | |
| best_idx = int(np.argmax(group_rewards)) | |
| best_action, _, _, best_fb = candidates[best_idx] | |
| obs, reward, done, info = env.step(best_action) | |
| total_steps += 1 | |
| if best_fb: | |
| fallback_steps += 1 | |
| # ββ Episode end βββββββββββββββββββββββββββββββββββββββββββββββ | |
| final_rewards = env.reward_calc.get_rewards() | |
| detective_reward = final_rewards["detective"] | |
| reward_log.append(detective_reward) | |
| if info.get("action") == "accuse": | |
| result = "correct" if info.get("correct") else "wrong" | |
| else: | |
| result = "timeout" | |
| results_log.append(result) | |
| correct_history.append(1 if result == "correct" else 0) | |
| if len(correct_history) > CURRICULUM_WINDOW * 3: | |
| correct_history.pop(0) | |
| if (episode + 1) in TRANSCRIPT_EPISODES: | |
| transcript_log.append( | |
| { | |
| "episode": episode + 1, | |
| "difficulty": difficulty, | |
| "result": result, | |
| "detective_reward": round(detective_reward, 4), | |
| "turns": env.turn, | |
| "criminal": env.case.get("criminal") if env.case else None, | |
| "crime": env.case.get("crime") if env.case else None, | |
| "location": env.case.get("location") if env.case else None, | |
| "conversation_history": list(env.conversation_history), | |
| "evidence_log": list(env.evidence_log), | |
| } | |
| ) | |
| fallback_rate = fallback_steps / max(1, total_steps) | |
| fallback_rate_history.append(fallback_rate) | |
| if len(fallback_rate_history) > max(1, STRICT_FORMAT_WINDOW): | |
| fallback_rate_history.pop(0) | |
| rolling_fallback_rate = sum(fallback_rate_history) / len(fallback_rate_history) | |
| strict_action_format = rolling_fallback_rate > STRICT_FORMAT_FALLBACK_THRESHOLD | |
| elapsed = time.time() - t0 | |
| avg_loss = total_loss_sum / max(1, grpo_updates) | |
| print( | |
| f"Episode {episode + 1:>3}/{NUM_EPISODES} | " | |
| f"Diff: {difficulty:<6} | " | |
| f"Result: {result:<7} | " | |
| f"Reward: {detective_reward:>+7.2f} | " | |
| f"Turns: {env.turn:>2} | " | |
| f"GRPO: {grpo_updates:>2} steps | " | |
| f"Loss: {avg_loss:>6.4f} | " | |
| f"Fallback: {fallback_steps}/{max(1, total_steps)} " | |
| f"({fallback_rate*100:>5.1f}%) | " | |
| f"Time: {elapsed:.1f}s" | |
| ) | |
| checkpoint_dir = f"./checkpoint_ep{episode + 1}" if (episode + 1) % 25 == 0 else None | |
| save_training_artifacts(checkpoint_dir=checkpoint_dir) | |
| if checkpoint_dir: | |
| print(f"Checkpoint saved: {checkpoint_dir}") | |
| # ββ Final save and summary ββββββββββββββββββββββββββββββββββββββββββββ | |
| save_training_artifacts() | |
| print(f"\nReward log saved to {REWARDS_FILE}") | |
| print(f"Episode transcripts saved to {TRANSCRIPTS_FILE}") | |
| print("\n" + "=" * 60) | |
| print(" TRAINING SUMMARY") | |
| print("=" * 60) | |
| print(f" Episodes: {NUM_EPISODES}") | |
| print(f" Correct accusations: {results_log.count('correct')}") | |
| print(f" Wrong accusations: {results_log.count('wrong')}") | |
| print(f" Timeouts: {results_log.count('timeout')}") | |
| print(f" Mean reward (last 50): {np.mean(reward_log[-50:]):.2f}") | |
| print(f" Mean reward (first 50): {np.mean(reward_log[:50]):.2f}") | |
| print("=" * 60) | |
| def _plot_reward_curve(reward_log: list[float]) -> None: | |
| """Plot and save the reward curve.""" | |
| fig, ax = plt.subplots(figsize=(12, 5)) | |
| episodes = np.arange(1, len(reward_log) + 1) | |
| ax.plot(episodes, reward_log, alpha=0.3, color="#4a90d9", label="Per-episode") | |
| window = min(20, len(reward_log) // 4) | |
| if window > 1: | |
| smoothed = np.convolve(reward_log, np.ones(window) / window, mode="valid") | |
| ax.plot( | |
| episodes[window - 1:], smoothed, | |
| color="#e74c3c", linewidth=2, label=f"Rolling avg ({window} ep)", | |
| ) | |
| ax.set_xlabel("Episode", fontsize=12) | |
| ax.set_ylabel("Detective Reward", fontsize=12) | |
| ax.set_title("AI Crime Investigation β Detective Reward Over Training", fontsize=14) | |
| ax.legend() | |
| ax.grid(True, alpha=0.3) | |
| ax.axhline(y=0, color="grey", linestyle="--", alpha=0.5) | |
| plt.tight_layout() | |
| plt.savefig("reward_curve.png", dpi=150) | |
| print("Reward curve saved to reward_curve.png") | |
| plt.close(fig) | |
| # ββ Entry Point βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| train() | |