rudeparis commited on
Commit
da1f428
·
verified ·
1 Parent(s): e9991c1

Upload eval_baseline.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. 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()