""" eval_baseline.py — Before/After Comparison ============================================ Demonstrates that GRPO training actually improved the agent's behavior. Compares: 1. Untrained baseline — Qwen3-1.7B with the system prompt only 2. Trained model — Qwen3-1.7B + LoRA adapter from grpo_output/ On the SAME 5 episodes (deterministic seeds), running the same investigation loop and reporting per-episode and mean scores. This is the script judges run to verify the training worked. It produces: - eval_baseline_vs_trained.json (raw numbers) - eval_baseline_vs_trained.png (side-by-side bar chart) Usage: python eval_baseline.py # or from Colab: %run eval_baseline.py """ import os import sys import re import json import random import torch import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # ── Config ───────────────────────────────────────────────────────── MODEL_NAME = os.environ.get("MODEL_NAME", "Qwen/Qwen3-1.7B") ADAPTER_DIR = "./grpo_output" EVAL_SEEDS = [42, 137, 256, 512, 1024] # 5 deterministic episodes DIFFICULTY = "easy" MAX_NEW_TOKENS = 768 OUT_JSON = os.path.join(ADAPTER_DIR, "eval_baseline_vs_trained.json") OUT_PLOT = os.path.join(ADAPTER_DIR, "eval_baseline_vs_trained.png") os.makedirs(ADAPTER_DIR, exist_ok=True) # ── System prompt (must match training) ──────────────────────────── SYSTEM_PROMPT = """You are an expert security code investigator. You have 6 tools: read_file, search_code, get_function_list, flag_vulnerable, skip_file, submit_report. Investigate the patch, flag vulnerable files with detailed reasoning, skip safe files briefly, and submit a triage report. Use tool calls in this format: {"name": "tool_name", "arguments": {...}}""" # ── Tool-call execution against live env ─────────────────────────── def parse_tool_calls(text): calls = [] for m in re.finditer(r'\s*(\{.*?\})\s*', text, re.DOTALL): try: data = json.loads(m.group(1)) name = data.get("name", "") args = data.get("arguments", {}) if isinstance(args, str): args = json.loads(args) if name: calls.append({"name": name, "args": args}) except (json.JSONDecodeError, AttributeError): pass return calls def execute_episode(env, calls): """Execute parsed tool calls and return the final TOTAL SCORE.""" from openenv.core.env_server import CallToolAction final_score = 0.0 last_text = "" for call in calls[:25]: # cap to prevent runaway try: obs = env.step(CallToolAction(tool_name=call["name"], arguments=call["args"])) text = str(obs.result.data if hasattr(obs.result, 'data') else obs.result) last_text = text if "TOTAL SCORE:" in text: m = re.search(r'TOTAL SCORE: ([\d.]+)', text) if m: final_score = float(m.group(1)) break except Exception: continue # Auto-submit if model never did if final_score == 0.0 and "INVESTIGATION COMPLETE" not in last_text: try: obs = env.step(CallToolAction(tool_name="submit_report", arguments={"summary": "Auto-submitted", "confidence": "low"})) text = str(obs.result.data if hasattr(obs.result, 'data') else obs.result) m = re.search(r'TOTAL SCORE: ([\d.]+)', text) if m: final_score = float(m.group(1)) except Exception: pass return final_score def build_prompt(context, tokenizer): msgs = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"{context}\n\nBegin investigation."}, ] return tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True) def run_single_episode(model, tokenizer, seed): """Run ONE episode end-to-end and return its score.""" from code_review_env.server.environment import CodeReviewEnvironment env = CodeReviewEnvironment() obs = env.reset(seed=seed, difficulty=DIFFICULTY) context = obs.metadata.get("context", "") prompt = build_prompt(context, tokenizer) inputs = tokenizer(prompt, return_tensors="pt").to(model.device) with torch.no_grad(): out = model.generate( **inputs, max_new_tokens=MAX_NEW_TOKENS, do_sample=False, temperature=1.0, top_p=1.0, pad_token_id=tokenizer.eos_token_id, ) generated = tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True) calls = parse_tool_calls(generated) score = execute_episode(env, calls) if calls else 0.0 return score, len(calls), len(generated) # ── Load each model and evaluate ─────────────────────────────────── def evaluate(adapter_path=None, label="Baseline"): print(f"\n{'='*70}\n Evaluating: {label}\n{'='*70}") from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig bnb = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True, ) tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token model = AutoModelForCausalLM.from_pretrained( MODEL_NAME, quantization_config=bnb, device_map="auto", torch_dtype=torch.bfloat16, ) # Install the same dtype-safety hook used in training def _hook(module, args, kwargs): target = module.weight.dtype new_args = tuple(a.to(target) if (torch.is_tensor(a) and a.is_floating_point() and a.dtype != target) else a for a in args) new_kwargs = {k: (v.to(target) if (torch.is_tensor(v) and v.is_floating_point() and v.dtype != target) else v) for k, v in kwargs.items()} return (new_args, new_kwargs) if hasattr(model, "lm_head"): model.lm_head.register_forward_pre_hook(_hook, with_kwargs=True) if adapter_path and os.path.exists(os.path.join(adapter_path, "adapter_config.json")): from peft import PeftModel model = PeftModel.from_pretrained(model, adapter_path) print(f" Loaded LoRA adapter from {adapter_path}") else: print(f" No adapter loaded — pure base model") model.eval() scores = [] for seed in EVAL_SEEDS: score, n_calls, gen_len = run_single_episode(model, tokenizer, seed) scores.append(score) print(f" seed={seed:5d} score={score:.3f} tool_calls={n_calls:2d} gen_chars={gen_len}") mean = sum(scores) / len(scores) print(f" MEAN: {mean:.3f}") del model torch.cuda.empty_cache() return scores, mean # ── Plot ─────────────────────────────────────────────────────────── def plot_comparison(baseline_scores, trained_scores): fig, ax = plt.subplots(figsize=(11, 6)) n = len(baseline_scores) x = list(range(n)) width = 0.38 ax.bar([i - width/2 for i in x], baseline_scores, width, label=f"Baseline (untrained) mean={sum(baseline_scores)/n:.3f}", color="#94a3b8", edgecolor="#475569") ax.bar([i + width/2 for i in x], trained_scores, width, label=f"Trained (GRPO) mean={sum(trained_scores)/n:.3f}", color="#7c3aed", edgecolor="#5b21b6") ax.set_xticks(x) ax.set_xticklabels([f"seed={s}" for s in EVAL_SEEDS]) ax.set_ylabel("Total Score (env reward)", fontsize=12) ax.set_title("CodeReviewEnv v3 — Baseline vs GRPO-Trained Qwen3-1.7B", fontsize=14, fontweight='bold') ax.set_ylim(0, max(1.0, max(baseline_scores + trained_scores) * 1.2)) ax.legend(fontsize=11) ax.grid(True, alpha=0.3, axis='y') # Annotate improvement delta = sum(trained_scores)/n - sum(baseline_scores)/n ax.text(0.02, 0.97, f"Δ = {delta:+.3f}", transform=ax.transAxes, fontsize=14, fontweight='bold', verticalalignment='top', bbox=dict(boxstyle='round,pad=0.5', facecolor='#fef3c7', edgecolor='#f59e0b')) plt.tight_layout() plt.savefig(OUT_PLOT, dpi=200, bbox_inches='tight') plt.close() print(f"\nSaved comparison plot: {OUT_PLOT}") # ── Main ─────────────────────────────────────────────────────────── def main(): print(f"Evaluating on {len(EVAL_SEEDS)} episodes (seeds={EVAL_SEEDS})") baseline_scores, baseline_mean = evaluate(adapter_path=None, label="Baseline (untrained)") trained_scores, trained_mean = evaluate(adapter_path=ADAPTER_DIR, label="Trained (GRPO)") delta = trained_mean - baseline_mean pct = (delta / baseline_mean * 100) if baseline_mean > 0 else float('inf') print(f"\n{'='*70}\n COMPARISON\n{'='*70}") print(f" Baseline mean: {baseline_mean:.3f}") print(f" Trained mean: {trained_mean:.3f}") print(f" Improvement: {delta:+.3f} ({pct:+.1f}%)") results = { "model": MODEL_NAME, "adapter": ADAPTER_DIR, "difficulty": DIFFICULTY, "seeds": EVAL_SEEDS, "baseline_scores": baseline_scores, "trained_scores": trained_scores, "baseline_mean": baseline_mean, "trained_mean": trained_mean, "improvement": delta, "improvement_pct": pct, } with open(OUT_JSON, "w") as f: json.dump(results, f, indent=2) print(f"\nSaved comparison data: {OUT_JSON}") plot_comparison(baseline_scores, trained_scores) if __name__ == "__main__": main()