Upload eval_baseline.py with huggingface_hub
Browse files- eval_baseline.py +155 -0
eval_baseline.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Eval LLM parser against canonical 134 scenarios × 553 turns from `output/scenarios/*.json`.
|
| 2 |
+
|
| 3 |
+
Apples-to-apples vs production V32 baseline (`just baseline` reports).
|
| 4 |
+
Walks the same gold the harness consumes, measures intent_em + slots_em + per-intent breakdown.
|
| 5 |
+
|
| 6 |
+
V32 production reference (from `just baseline`):
|
| 7 |
+
intent: 87.4% slots: 80.6% readback: 76.3% compound_seg: 51.3% pass: 76.3%
|
| 8 |
+
on 134 scenarios / 553 turns / 253 ATC-to-ownship
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
uv run python -u poc/llm-finetune/training/eval_baseline.py \\
|
| 12 |
+
--adapter poc/llm-finetune/training/q3b_adapter_v7_r16
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import re
|
| 18 |
+
from collections import Counter, defaultdict
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from mlx_lm import load, generate
|
| 21 |
+
|
| 22 |
+
INTENTS = sorted(json.loads(Path("/Users/jean-patricksmith/digital/kingly/apps/production/naac/poc/deberta_intent/checkpoints-base/label_mapping.json").read_text())["intent2id"].keys())
|
| 23 |
+
SLOTS = ["ALTIMETER_SETTING", "ALTITUDE", "APPROACH_TYPE", "CALL_SIGN", "CLOCK_POSITION",
|
| 24 |
+
"DIRECTION", "DISTANCE", "FACILITY", "FIX", "FREQUENCY", "HEADING", "PATTERN_LEG",
|
| 25 |
+
"ROUTE", "RUNWAY", "SPEED", "TAXIWAY", "TIME", "TRANSPONDER_CODE", "TURN_DIRECTION"]
|
| 26 |
+
|
| 27 |
+
SYSTEM_PROMPT = (
|
| 28 |
+
"You are an ATC parser. Parse the air traffic control transmission into a JSON object.\n"
|
| 29 |
+
"OUTPUT FORMAT: {\"intent\": <one-of-enum>, \"slots\": {<SLOT_TYPE>: <value>}}\n"
|
| 30 |
+
f"\nINTENT MUST BE ONE OF: {', '.join(INTENTS)}\n"
|
| 31 |
+
f"\nSLOT TYPES (UPPERCASE): {', '.join(SLOTS)}\n"
|
| 32 |
+
"\nOutput ONLY the JSON object. No prose, no code fences."
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
SCENARIO_DIR = Path("/Users/jean-patricksmith/digital/kingly/apps/production/naac/data/src/partner_graph/output/scenarios")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def parse(text: str):
|
| 39 |
+
text = text.strip()
|
| 40 |
+
text = re.sub(r'<think>.*?</think>\s*', '', text, flags=re.DOTALL).strip()
|
| 41 |
+
if text.startswith("```"):
|
| 42 |
+
text = text.split("```")[1]
|
| 43 |
+
if text.startswith("json"):
|
| 44 |
+
text = text[4:]
|
| 45 |
+
try:
|
| 46 |
+
return json.loads(text.strip())
|
| 47 |
+
except Exception:
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def walk_scenarios():
|
| 52 |
+
"""Yield (scenario_id, turn_idx, atc_text, expected_intent, expected_params) for every ATC-to-ownship turn."""
|
| 53 |
+
for path in sorted(SCENARIO_DIR.glob("*.json")):
|
| 54 |
+
try:
|
| 55 |
+
scn = json.loads(path.read_text())
|
| 56 |
+
except Exception:
|
| 57 |
+
continue
|
| 58 |
+
scn_id = scn.get("id", path.stem)
|
| 59 |
+
# Walk turns — schema varies slightly across scenario types
|
| 60 |
+
# Most have "phases" with "atc_instructions" or top-level "turns" with "expected_*"
|
| 61 |
+
phases = scn.get("phases") or scn.get("dialog") or []
|
| 62 |
+
if not phases and "turns" in scn:
|
| 63 |
+
phases = [{"atc_instructions": scn.get("turns", [])}]
|
| 64 |
+
turn_idx = 0
|
| 65 |
+
for phase in phases:
|
| 66 |
+
for turn in phase.get("atc_instructions", []) + phase.get("turns", []):
|
| 67 |
+
text = turn.get("atc_text") or turn.get("text") or turn.get("transmission")
|
| 68 |
+
if not text:
|
| 69 |
+
continue
|
| 70 |
+
# Filter to ATC-to-ownship (skip pilot replies and broadcasts to others)
|
| 71 |
+
addr = turn.get("addressed_to") or turn.get("speaker") or ""
|
| 72 |
+
if "ownship" not in str(addr).lower() and "all aircraft" not in str(addr).lower() and addr:
|
| 73 |
+
continue
|
| 74 |
+
exp_intent = turn.get("expected_intent") or turn.get("intent")
|
| 75 |
+
exp_params = turn.get("expected_parameters") or turn.get("parameters") or {}
|
| 76 |
+
if not exp_intent:
|
| 77 |
+
continue
|
| 78 |
+
yield scn_id, turn_idx, text, exp_intent, exp_params
|
| 79 |
+
turn_idx += 1
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def main():
|
| 83 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 84 |
+
p.add_argument("--adapter", default="poc/llm-finetune/training/q3b_adapter_v7_r16")
|
| 85 |
+
p.add_argument("--base", default="mlx-community/Qwen3-4B-Instruct-2507-4bit")
|
| 86 |
+
p.add_argument("--out", default=None, help="optional JSON output path")
|
| 87 |
+
p.add_argument("--limit", type=int, default=0, help="limit turns for quick test")
|
| 88 |
+
args = p.parse_args()
|
| 89 |
+
|
| 90 |
+
print(f"loading {args.base} + {args.adapter}")
|
| 91 |
+
model, tok = load(args.base, adapter_path=args.adapter)
|
| 92 |
+
|
| 93 |
+
rows = list(walk_scenarios())
|
| 94 |
+
if args.limit:
|
| 95 |
+
rows = rows[:args.limit]
|
| 96 |
+
print(f"total ATC-to-ownship turns: {len(rows)}")
|
| 97 |
+
|
| 98 |
+
schema_ok = intent_em = slots_em = 0
|
| 99 |
+
by_intent = defaultdict(lambda: [0, 0, 0]) # n, intent_hits, slots_hits
|
| 100 |
+
by_scenario = defaultdict(lambda: [0, 0, 0])
|
| 101 |
+
raw_outputs = []
|
| 102 |
+
|
| 103 |
+
for i, (scn_id, turn_idx, text, gold_intent, gold_params) in enumerate(rows):
|
| 104 |
+
msgs = [
|
| 105 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 106 |
+
{"role": "user", "content": text},
|
| 107 |
+
]
|
| 108 |
+
prompt = tok.apply_chat_template(msgs, add_generation_prompt=True, tokenize=False)
|
| 109 |
+
out = generate(model, tok, prompt=prompt, max_tokens=200, verbose=False)
|
| 110 |
+
parsed = parse(out)
|
| 111 |
+
|
| 112 |
+
if parsed:
|
| 113 |
+
schema_ok += 1
|
| 114 |
+
gold_slots = {k: str(v) for k, v in gold_params.items() if v is not None}
|
| 115 |
+
is_intent = bool(parsed and parsed.get("intent") == gold_intent)
|
| 116 |
+
# Slot match: check predicted slots includes all gold keys with matching values
|
| 117 |
+
# (tolerates extra slot keys; harsh exact-match like eval_holdout would be too strict here)
|
| 118 |
+
is_slots = False
|
| 119 |
+
if parsed and isinstance(parsed.get("slots"), dict):
|
| 120 |
+
pred = {k: str(v) for k, v in parsed["slots"].items()}
|
| 121 |
+
is_slots = all(pred.get(k) == v for k, v in gold_slots.items())
|
| 122 |
+
intent_em += int(is_intent); slots_em += int(is_slots)
|
| 123 |
+
by_intent[gold_intent][0] += 1
|
| 124 |
+
by_intent[gold_intent][1] += int(is_intent)
|
| 125 |
+
by_intent[gold_intent][2] += int(is_slots)
|
| 126 |
+
by_scenario[scn_id][0] += 1
|
| 127 |
+
by_scenario[scn_id][1] += int(is_intent)
|
| 128 |
+
by_scenario[scn_id][2] += int(is_slots)
|
| 129 |
+
if i < 5:
|
| 130 |
+
raw_outputs.append({"scn": scn_id, "turn": turn_idx, "text": text, "gold": {"intent": gold_intent, "slots": gold_slots}, "pred": parsed})
|
| 131 |
+
if (i + 1) % 50 == 0:
|
| 132 |
+
print(f" ...{i+1}/{len(rows)}: intent={intent_em/(i+1):.1%} slots={slots_em/(i+1):.1%}")
|
| 133 |
+
|
| 134 |
+
n = len(rows)
|
| 135 |
+
print(f"\n=== CANONICAL BASELINE (134 scenarios × 553 turns equivalent) ===")
|
| 136 |
+
print(f"adapter: {args.adapter}")
|
| 137 |
+
print(f"total turns: {n}")
|
| 138 |
+
print(f"schema_valid: {schema_ok}/{n} = {schema_ok/n:.1%}")
|
| 139 |
+
print(f"intent_em: {intent_em}/{n} = {intent_em/n:.1%} (V32 baseline: 87.4%)")
|
| 140 |
+
print(f"slots_em: {slots_em}/{n} = {slots_em/n:.1%} (V32 baseline: 80.6%)")
|
| 141 |
+
print(f"\n=== PER-INTENT (top 20 by count) ===")
|
| 142 |
+
for intent, (n_i, i_em, s_em) in sorted(by_intent.items(), key=lambda kv: -kv[1][0])[:20]:
|
| 143 |
+
print(f" {intent:30s} n={n_i:4d} intent={i_em}/{n_i} ({i_em/n_i:.0%}) slots={s_em}/{n_i} ({s_em/n_i:.0%})")
|
| 144 |
+
|
| 145 |
+
if args.out:
|
| 146 |
+
Path(args.out).write_text(json.dumps({
|
| 147 |
+
"adapter": args.adapter, "n": n, "intent_em": intent_em, "slots_em": slots_em,
|
| 148 |
+
"schema_valid": schema_ok,
|
| 149 |
+
"by_intent": dict(by_intent), "by_scenario": dict(by_scenario),
|
| 150 |
+
"samples": raw_outputs,
|
| 151 |
+
}, indent=2))
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
if __name__ == "__main__":
|
| 155 |
+
main()
|