# /// script # dependencies = [ # "openai>=1.0", # "azure-identity", # "azure-ai-projects", # ] # /// """ calibrate_grader.py — Calibrate RFT grader pass_threshold before submitting a job. Runs the base model on your training/validation data, scores each output with your Python grader, and recommends the optimal pass_threshold. Usage: python calibrate_grader.py --base-url --api-key KEY \ --model o4-mini --data train.jsonl --grader grader.py --n 30 python calibrate_grader.py --model gpt-4.1-mini --data val.jsonl \ --grader grader.py --n 20 --tools '[{"name": "search", "server_url": "https://..."}]' """ import argparse import json import os import random import sys try: sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") except (AttributeError, OSError): pass # Stream not reconfigurable (older Python or non-tty); default encoding is fine import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from common import HelpOnErrorParser, get_clients def load_grader(grader_path): """Load and compile a Python grader file. Returns the grade() function. SECURITY: This executes the grader file as Python code. Only load grader files that you wrote or reviewed — never load untrusted files from the internet or unknown sources. The grader runs with the same permissions as this script. """ grader_path = os.path.abspath(grader_path) if not os.path.isfile(grader_path): print(f"❌ Grader file not found: {grader_path}") sys.exit(1) with open(grader_path, encoding="utf-8") as f: source = f.read() namespace = {} exec(compile(source, grader_path, "exec"), namespace) if "grade" not in namespace: print(f"❌ Grader file must define a grade(sample, item) function") sys.exit(1) return namespace["grade"] def run_model(client, model, messages, tools_schema=None, max_retries=3): """Run the model and return (output_text, output_tools).""" kwargs = {"model": model, "messages": messages, "max_completion_tokens": 4096} if tools_schema: kwargs["tools"] = tools_schema for attempt in range(max_retries): try: resp = client.chat.completions.create(**kwargs) msg = resp.choices[0].message output_text = msg.content or "" output_tools = [] if msg.tool_calls: output_tools = [ {"type": "function", "function": {"name": tc.function.name, "arguments": tc.function.arguments}} for tc in msg.tool_calls ] return output_text, output_tools except Exception as e: if "429" in str(e) and attempt < max_retries - 1: time.sleep(5 * (attempt + 1)) else: return f"ERROR: {e}", [] return "ERROR: max retries", [] def calibrate(client, model, data, grade_fn, tools_schema=None, n=30): """Run base model on data, score with grader, output threshold analysis.""" if not data: print("No examples to evaluate. Check your data file.") return # Sample if dataset is larger than n if len(data) > n: data = random.sample(data, n) print(f"Running {model} on {len(data)} examples...\n") scores = [] for i, ex in enumerate(data): messages = ex["messages"] user_msg = messages[-1]["content"] if messages else "" output_text, output_tools = run_model(client, model, messages, tools_schema) if output_text.startswith("ERROR:"): print(f" [{i+1:3d}] ❌ {output_text[:60]}") scores.append(0.0) continue # Build sample dict matching what the grader expects sample = {"output_text": output_text, "output_tools": output_tools} # Build item dict from all fields in the training example item = {k: v for k, v in ex.items() if k != "messages"} try: score = grade_fn(sample, item) except Exception as e: print(f" [{i+1:3d}] ❌ Grader error: {e}") scores.append(0.0) continue status = "✅" if score >= 0.9 else ("⚠️" if score >= 0.5 else "❌") print(f" [{i+1:3d}] {score:.3f} {status} {user_msg[:55]}") scores.append(score) time.sleep(0.5) # Rate limiting # Analysis scored = [s for s in scores if s is not None] if not scored: print("\n❌ No examples were scored successfully. Check model access and data format.") return avg = sum(scored) / len(scored) print(f"\n{'='*60}") print(f" BASE MODEL GRADER CALIBRATION ({len(scores)} examples)") print(f" Average score: {avg:.1%}") print(f"{'='*60}") print(f"\n {'Threshold':>10} {'Pass Rate':>10} {'Fail Rate':>10} {'Signal':>20}") print(f" {'-'*10} {'-'*10} {'-'*10} {'-'*20}") best_threshold = None best_distance = float("inf") for threshold in [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 1.0]: pass_rate = sum(1 for s in scored if s >= threshold) / len(scored) fail_rate = 1 - pass_rate if 0.25 <= fail_rate <= 0.50: signal = "✅ Good (25-50%)" distance = abs(fail_rate - 0.35) # Ideal is ~35% if distance < best_distance: best_distance = distance best_threshold = threshold elif fail_rate < 0.10: signal = "❌ Too easy" elif fail_rate < 0.25: signal = "⚠️ Weak signal" elif fail_rate <= 0.70: signal = "⚠️ Harsh" else: signal = "❌ Too hard" print(f" {threshold:>10.2f} {pass_rate:>9.0%} {fail_rate:>9.0%} {signal:>20}") if best_threshold: print(f"\n ✅ Recommended pass_threshold: {best_threshold}") print(f" (~{sum(1 for s in scores if s < best_threshold)/len(scores):.0%} failure rate)") else: print(f"\n ⚠️ No threshold in the ideal 25-50% failure range.") print(f" Consider adjusting your grader scoring dimensions.") # Score distribution print(f"\n Score distribution:") buckets = {"0.0-0.2": 0, "0.2-0.4": 0, "0.4-0.6": 0, "0.6-0.8": 0, "0.8-0.9": 0, "0.9-1.0": 0} for s in scores: if s < 0.2: buckets["0.0-0.2"] += 1 elif s < 0.4: buckets["0.2-0.4"] += 1 elif s < 0.6: buckets["0.4-0.6"] += 1 elif s < 0.8: buckets["0.6-0.8"] += 1 elif s < 0.9: buckets["0.8-0.9"] += 1 else: buckets["0.9-1.0"] += 1 for bucket, count in buckets.items(): bar = "█" * count print(f" {bucket}: {count:3d} {bar}") def build_parser(): parser = HelpOnErrorParser( description="Calibrate RFT grader pass_threshold on base model outputs", epilog=( "Example:\n" " python calibrate_grader.py --model o4-mini --data train.jsonl --grader grader.py\n" " python calibrate_grader.py --model o4-mini --data val.jsonl --grader grader.py --n 20" ), formatter_class=argparse.RawTextHelpFormatter, ) parser.add_argument("--base-url", default=os.environ.get("OPENAI_BASE_URL"), help="Project /v1/ endpoint URL") parser.add_argument("--endpoint", default=os.environ.get("AZURE_OPENAI_ENDPOINT"), help="Azure OpenAI endpoint (fallback)") parser.add_argument("--api-key", default=os.environ.get("AZURE_OPENAI_API_KEY"), help="API key") parser.add_argument("--project-endpoint", default=os.environ.get("AZURE_AI_PROJECT_ENDPOINT"), help="Azure AI project endpoint") parser.add_argument("--model", required=True, help="Base model deployment name to calibrate against") parser.add_argument("--data", required=True, help="Path to training or validation JSONL file") parser.add_argument("--grader", required=True, help="Path to Python grader file (must define grade(sample, item))") parser.add_argument("--n", type=int, default=30, help="Number of examples to evaluate (default: 30)") parser.add_argument("--tools", default=None, help="Tool schemas as JSON array (for tool-calling models). Pass as a JSON string.") parser.add_argument("--seed", type=int, default=42, help="Random seed for sampling (default: 42)") return parser if __name__ == "__main__": parser = build_parser() if len(sys.argv) == 1: parser.print_help() sys.exit(0) args = parser.parse_args() random.seed(args.seed) client, method = get_clients(base_url=args.base_url, azure_endpoint=args.endpoint, project_endpoint=args.project_endpoint, api_key=args.api_key) # Load data with open(args.data, encoding="utf-8") as f: data = [] for ln, line in enumerate(f, 1): if not line.strip(): continue try: data.append(json.loads(line)) except json.JSONDecodeError as e: print(f"⚠️ Skipping malformed JSON on line {ln}: {e}") print(f"Loaded {len(data)} examples from {args.data}") # Load grader grade_fn = load_grader(args.grader) print(f"Loaded grader from {args.grader}") # Parse tools if provided tools_schema = None if args.tools: tools_schema = json.loads(args.tools) calibrate(client, args.model, data, grade_fn, tools_schema, args.n)