| """Generation eval for Brain-1/Brain-2 checkpoints (plans/09-eval-harness-spec.md). |
| |
| Generates greedily on `{DATA_DIR}/{dataset}/test.parquet` and scores structured |
| match. `--baseline` runs a second model on the same set and prints the delta |
| table (the SFT-2 forgetting check). Decision log: plans/09-eval-decisions.md. |
| |
| uv run --group train modal run train/eval.py --limit 64 # smoke |
| uv run --group train modal run train/eval.py # full SFT-1 |
| uv run --group train modal run train/eval.py --dataset ha_actions \ |
| --adapter /checkpoints/brain1/<sft2> --baseline /checkpoints/brain1/ha_actions |
| """ |
|
|
| import json |
| import re |
| import string |
| from collections import Counter |
|
|
| from modal_common import ( |
| CHATML, |
| CKPT_DIR, |
| DATA_DIR, |
| GPU_BRAIN1, |
| GPU_BRAIN2, |
| GPU_DEFAULTS, |
| VOLUMES, |
| app, |
| cfg, |
| rl_image, |
| train_image, |
| ) |
|
|
| |
| |
| with train_image.imports(): |
| import torch |
| from datasets import load_dataset |
| from peft import PeftModel |
| from transformers import AutoModelForCausalLM, AutoTokenizer |
|
|
| KNOWN_KEYS = ("intents", "response", "automation") |
|
|
|
|
| |
|
|
| def parse_channel(text: str): |
| """-> (channel, payload). channel=None if not a JSON object with exactly |
| one known top key; 'yaml' channel is decided by the GOLD side, not here.""" |
| try: |
| obj = json.loads(text) |
| except (json.JSONDecodeError, TypeError): |
| return None, None |
| if isinstance(obj, dict) and len(obj) == 1 and next(iter(obj)) in KNOWN_KEYS: |
| key = next(iter(obj)) |
| return key, obj[key] |
| return None, None |
|
|
|
|
| def norm_slot(v): |
| """'50', '50%', 50 and 50.0 all compare equal; strings casefold.""" |
| if isinstance(v, bool): |
| return v |
| if isinstance(v, (int, float)): |
| return float(v) |
| if isinstance(v, str): |
| s = v.strip().lower() |
| try: |
| return float(s.removesuffix("%").strip()) |
| except ValueError: |
| return s |
| return v |
|
|
|
|
| def call_sig(intent: dict): |
| slots = intent.get("slots") or {} |
| if not isinstance(slots, dict): |
| slots = {} |
| return (intent.get("name"), tuple(sorted((k, repr(norm_slot(v))) for k, v in slots.items()))) |
|
|
|
|
| def norm_text(s: str) -> str: |
| """SQuAD-style: casefold, drop punctuation + articles, collapse spaces.""" |
| s = s.lower().translate(str.maketrans("", "", string.punctuation)) |
| s = re.sub(r"\b(a|an|the)\b", " ", s) |
| return " ".join(s.split()) |
|
|
|
|
| def token_f1(gold: str, pred: str) -> float: |
| g, p = norm_text(gold).split(), norm_text(pred).split() |
| common = sum((Counter(g) & Counter(p)).values()) |
| if not common: |
| return 0.0 |
| prec, rec = common / len(p), common / len(g) |
| return 2 * prec * rec / (prec + rec) |
|
|
|
|
| def _intent_list(payload): |
| if not isinstance(payload, list): |
| return None |
| if not all(isinstance(i, dict) and i.get("name") for i in payload): |
| return None |
| return payload |
|
|
|
|
| def slices_of(gold_intents: list[dict]) -> list[str]: |
| """Failure clusters called out in the spec, keyed on the GOLD call.""" |
| out = [] |
| if len(gold_intents) > 1: |
| out.append("multi_intent") |
| for it in gold_intents: |
| name, slots = it.get("name", ""), it.get("slots") or {} |
| target = str(slots.get("name", "")).lower() |
| if name in ("HassTurnOn", "HassTurnOff") and ( |
| "lock" in target or slots.get("domain") == "lock" |
| ): |
| out.append("locks") |
| if "Timer" in name or "Vacuum" in name: |
| out.append("timer_vacuum") |
| if name in ( |
| "HassSetPosition", |
| "HassLightSet", |
| "HassClimateSetTemperature", |
| "HassSetVolume", |
| "HassHumidifierSetpoint", |
| "HassHumidifierMode", |
| ): |
| out.append("attr_calls") |
| return sorted(set(out)) |
|
|
|
|
| def score_row(gold_text: str, pred_text: str, sys_prompt: str = "") -> dict: |
| gold_ch, gold_pl = parse_channel(gold_text) |
| if gold_ch is None: |
| return _score_yaml(gold_text, pred_text) |
|
|
| pred_ch, pred_pl = parse_channel(pred_text) |
| r = { |
| "bucket": gold_ch, |
| "json_valid": pred_ch is not None, |
| "route_ok": pred_ch == gold_ch, |
| "slices": [], |
| } |
| if gold_ch == "intents": |
| gold_il = _intent_list(gold_pl) or [] |
| r["slices"] = slices_of(gold_il) |
| |
| |
| if sys_prompt: |
| names = [str((i.get("slots") or {}).get("name", "")) for i in gold_il] |
| r["gold_grounded"] = all((not n) or n in sys_prompt for n in names) |
| pred_il = _intent_list(pred_pl) if pred_ch == "intents" else None |
| if pred_il is None: |
| r.update(name_match=False, full_set=False, full_ord=False) |
| else: |
| gs, ps = [call_sig(i) for i in gold_il], [call_sig(i) for i in pred_il] |
| r["name_match"] = Counter(i["name"] for i in gold_il) == Counter( |
| i["name"] for i in pred_il |
| ) |
| r["full_set"] = sorted(map(repr, gs)) == sorted(map(repr, ps)) |
| r["full_ord"] = gs == ps |
| elif gold_ch == "response": |
| p = pred_pl if (pred_ch == "response" and isinstance(pred_pl, str)) else "" |
| r["exact"] = bool(p) and norm_text(gold_pl) == norm_text(p) |
| r["f1"] = token_f1(gold_pl, p) if p else 0.0 |
| elif gold_ch == "automation": |
| p = pred_pl if (pred_ch == "automation" and isinstance(pred_pl, str)) else "" |
| r["verbatim"] = p == gold_pl |
| r["norm_match"] = bool(p) and norm_text(gold_pl) == norm_text(p) |
| return r |
|
|
|
|
| def _score_yaml(gold_text: str, pred_text: str) -> dict: |
| import yaml |
|
|
| def load(s): |
| try: |
| doc = yaml.safe_load(s) |
| return doc if isinstance(doc, (dict, list)) else None |
| except yaml.YAMLError: |
| return None |
|
|
| gold_doc, pred_doc = load(gold_text), load(pred_text) |
| return { |
| "bucket": "yaml", |
| "json_valid": pred_doc is not None, |
| "route_ok": pred_doc is not None, |
| "slices": [], |
| "struct_match": gold_doc is not None and gold_doc == pred_doc, |
| "exact": gold_text.strip() == pred_text.strip(), |
| } |
|
|
|
|
| def _grounded_split(action_rows, flag, pct): |
| sel = [r for r in action_rows if r.get("gold_grounded") is flag] |
| return {"n": len(sel), "full_set": pct(sel, "full_set")} if sel else None |
|
|
|
|
| def aggregate(rows: list[dict]) -> dict: |
| def pct(items, key): |
| items = [r for r in items if key in r] |
| return round(100 * sum(r[key] for r in items) / len(items), 2) if items else None |
|
|
| by = {b: [r for r in rows if r["bucket"] == b] for b in (*KNOWN_KEYS, "yaml")} |
| m = { |
| "rows": len(rows), |
| "json_valid": pct(rows, "json_valid"), |
| "routing": pct(rows, "route_ok"), |
| "buckets": {b: len(v) for b, v in by.items() if v}, |
| "action": { |
| "n": len(by["intents"]), |
| "name_match": pct(by["intents"], "name_match"), |
| "full_set": pct(by["intents"], "full_set"), |
| "full_ord": pct(by["intents"], "full_ord"), |
| "grounded": _grounded_split(by["intents"], True, pct), |
| "ungrounded": _grounded_split(by["intents"], False, pct), |
| }, |
| "response": { |
| "n": len(by["response"]), |
| "exact": pct(by["response"], "exact"), |
| "f1": round( |
| sum(r["f1"] for r in by["response"]) / len(by["response"]), 3 |
| ) |
| if by["response"] |
| else None, |
| }, |
| "automation": { |
| "n": len(by["automation"]), |
| "verbatim": pct(by["automation"], "verbatim"), |
| "norm_match": pct(by["automation"], "norm_match"), |
| }, |
| "yaml": { |
| "n": len(by["yaml"]), |
| "valid": pct(by["yaml"], "json_valid"), |
| "struct_match": pct(by["yaml"], "struct_match"), |
| }, |
| "slices": {}, |
| } |
| for s in ("multi_intent", "locks", "timer_vacuum", "attr_calls"): |
| sl = [r for r in by["intents"] if s in r["slices"]] |
| if sl: |
| m["slices"][s] = {"n": len(sl), "full_set": pct(sl, "full_set")} |
| return m |
|
|
|
|
| def report(m: dict, title: str) -> str: |
| L = [f"==== {title} | {m['rows']} rows ===="] |
| L.append(f"json-valid {m['json_valid']}% routing {m['routing']}%") |
| a = m["action"] |
| if a["n"]: |
| L.append( |
| f"ACTION ({a['n']:>5}): names {a['name_match']}% | " |
| f"full-call set {a['full_set']}% | ordered {a['full_ord']}%" |
| ) |
| if a.get("grounded"): |
| g, u = a["grounded"], a.get("ungrounded") or {"n": 0, "full_set": "-"} |
| L.append( |
| f" gold GROUNDED ({g['n']:>4}): full-call {g['full_set']}% " |
| f"| corrupted-gold ({u['n']:>4}): {u['full_set']}% <- headline is GROUNDED" |
| ) |
| r = m["response"] |
| if r["n"]: |
| L.append(f"RESPONSE ({r['n']:>5}): exact {r['exact']}% | token-F1 {r['f1']}") |
| au = m["automation"] |
| if au["n"]: |
| L.append( |
| f"AUTOMATION ({au['n']:>5}): verbatim {au['verbatim']}% | " |
| f"normalized {au['norm_match']}%" |
| ) |
| y = m["yaml"] |
| if y["n"]: |
| L.append(f"YAML ({y['n']:>5}): valid {y['valid']}% | struct {y['struct_match']}%") |
| for s, v in m["slices"].items(): |
| L.append(f" slice {s:<13} ({v['n']:>4}): full-call set {v['full_set']}%") |
| return "\n".join(L) |
|
|
|
|
| def delta_report(base: dict, new: dict) -> str: |
| """Forgetting table: new minus baseline on the same test set (BWT-style, |
| Lopez-Paz & Ranzato 2017). Negative delta on old-skill metrics = forgetting.""" |
| pairs = [ |
| ("json-valid %", ("json_valid",)), |
| ("routing %", ("routing",)), |
| ("action names %", ("action", "name_match")), |
| ("action full-call set %", ("action", "full_set")), |
| ("action full-call GROUNDED %", ("action", "grounded", "full_set")), |
| ("action full-call ordered %", ("action", "full_ord")), |
| ("response exact %", ("response", "exact")), |
| ("automation verbatim %", ("automation", "verbatim")), |
| ] |
| L = [f"{'metric':<28}{'baseline':>10}{'new':>10}{'delta':>10}"] |
| for label, path in pairs: |
| b, n = base, new |
| for k in path: |
| b, n = b.get(k) if b else None, n.get(k) if n else None |
| if b is None and n is None: |
| continue |
| d = round(n - b, 2) if (b is not None and n is not None) else None |
| flag = " <- forgetting" if (d is not None and d < -1) else "" |
| L.append(f"{label:<28}{b!s:>10}{n!s:>10}{d!s:>10}{flag}") |
| return "\n".join(L) |
|
|
|
|
| |
|
|
| def _eval( |
| dataset: str, adapter: str, base_model: str, limit: int, batch: int, max_new: int, |
| split: str = "test", |
| ) -> dict: |
| src = adapter or base_model |
| print(f"[eval] cuda={torch.cuda.is_available()} src={src} dataset={dataset}") |
| tok = AutoTokenizer.from_pretrained(src, trust_remote_code=True) |
| model = AutoModelForCausalLM.from_pretrained( |
| base_model, torch_dtype=torch.bfloat16, trust_remote_code=True |
| ).to("cuda") |
| if adapter: |
| model = PeftModel.from_pretrained(model, adapter) |
| model.eval() |
| |
| |
| turn_end = "<|im_end|>" if "<|im_end|>" in tok.get_vocab() else tok.eos_token |
| tok.chat_template = CHATML.replace("<|im_end|>", turn_end) |
| tok.padding_side = "left" |
| if tok.pad_token is None: |
| tok.pad_token = tok.eos_token or turn_end |
| end_id = tok.convert_tokens_to_ids(turn_end) |
| stop_ids = [end_id] + ([tok.eos_token_id] if tok.eos_token_id not in (None, end_id) else []) |
|
|
| |
| |
| ds = load_dataset("parquet", data_files=f"{DATA_DIR}/{dataset}/{split}.parquet", split="train") |
| if limit: |
| ds = ds.select(range(min(limit, len(ds)))) |
| |
| |
| if "messages" in ds.column_names: |
| prompt_msgs = [m[:-1] for m in ds["messages"]] |
| golds = [m[-1]["content"] for m in ds["messages"]] |
| else: |
| prompt_msgs = list(ds["prompt"]) |
| golds = [c[-1]["content"] for c in ds["completion"]] |
| sys_prompts = [p[0]["content"] for p in prompt_msgs] |
| users = [p[-1]["content"] for p in prompt_msgs] |
| |
| |
| prompts = [ |
| tok.apply_chat_template(p, tokenize=False, add_generation_prompt=True) |
| for p in prompt_msgs |
| ] |
|
|
| preds = [] |
| for i in range(0, len(prompts), batch): |
| enc = tok(prompts[i : i + batch], return_tensors="pt", padding=True).to("cuda") |
| with torch.inference_mode(): |
| out = model.generate( |
| **enc, |
| max_new_tokens=max_new, |
| do_sample=False, |
| eos_token_id=stop_ids, |
| pad_token_id=tok.pad_token_id, |
| ) |
| texts = tok.batch_decode(out[:, enc["input_ids"].shape[1] :], skip_special_tokens=False) |
| preds += [t.split(turn_end)[0].strip() for t in texts] |
| print(f"[eval] {len(preds)}/{len(prompts)}") |
|
|
| scored = [ |
| score_row(g, p, sys_prompt=s) for g, p, s in zip(golds, preds, sys_prompts) |
| ] |
| metrics = aggregate(scored) |
| tag = adapter.strip("/").replace("/", "_") if adapter else f"base_{base_model.split('/')[-1]}" |
| title = f"{dataset}/{split} @ {adapter or base_model} (raw greedy)" |
| print("\n" + report(metrics, title)) |
|
|
| fails = [ |
| {"i": i, "user": users[i], "gold": golds[i], "pred": preds[i]} |
| for i, r in enumerate(scored) |
| if not (r.get("route_ok") and r.get("full_set", r.get("exact", r.get("verbatim", True)))) |
| ] |
| print(f"\n[eval] {len(fails)} imperfect rows; first 8:") |
| for f in fails[:8]: |
| print(f" user: {f['user']}\n gold: {f['gold']}\n pred: {f['pred']}\n --") |
|
|
| out_dir = adapter or f"{CKPT_DIR}/evals" |
| import pathlib |
|
|
| pathlib.Path(out_dir).mkdir(parents=True, exist_ok=True) |
| |
| stem = f"eval_{dataset}" if adapter else f"{tag}_{dataset}" |
| if split != "test": |
| stem += f"_{split}" |
| out_file = f"{out_dir}/{stem}{f'_limit{limit}' if limit else ''}.json" |
| rows_dump = [ |
| {"i": i, "user": users[i], "gold": g, "pred": p, **r} |
| for i, (g, p, r) in enumerate(zip(golds, preds, scored)) |
| ] |
| with open(out_file, "w") as fh: |
| json.dump({"meta": {"dataset": dataset, "split": split, "adapter": adapter, |
| "base": base_model, "limit": limit, "mode": "raw_greedy", "tag": tag}, |
| "metrics": metrics, "rows": rows_dump}, fh, ensure_ascii=False, indent=1) |
| VOLUMES[CKPT_DIR].commit() |
| print(f"[eval] per-row results -> {out_file}") |
| return metrics |
|
|
|
|
| |
| _EVAL_OPTS = {**GPU_DEFAULTS, "retries": 0} |
| |
| |
| |
| _EVAL_OPTS_B2 = {**_EVAL_OPTS, "image": rl_image} |
|
|
|
|
| @app.function(gpu=GPU_BRAIN1, **_EVAL_OPTS) |
| def eval_brain1(dataset: str, adapter: str, base_model: str, limit: int, batch: int, max_new: int, split: str): |
| return _eval(dataset, adapter, base_model, limit, batch, max_new, split) |
|
|
|
|
| @app.function(gpu=GPU_BRAIN2, **_EVAL_OPTS_B2) |
| def eval_brain2(dataset: str, adapter: str, base_model: str, limit: int, batch: int, max_new: int, split: str): |
| return _eval(dataset, adapter, base_model, limit, batch, max_new, split) |
|
|
|
|
| @app.local_entrypoint() |
| def main( |
| dataset: str = "ha_actions", |
| adapter: str = f"{CKPT_DIR}/brain1/ha_actions", |
| baseline: str = "", |
| brain: str = "brain1", |
| limit: int = 0, |
| batch: int = 16, |
| max_new: int = 256, |
| split: str = "test", |
| ): |
| fn = {"brain1": eval_brain1, "brain2": eval_brain2}[brain] |
| base_model = cfg["base_models"][brain] |
| new = fn.remote(dataset, "" if adapter == "base" else adapter, base_model, limit, batch, max_new, split) |
| if baseline: |
| old = fn.remote( |
| dataset, "" if baseline == "base" else baseline, base_model, limit, batch, max_new, split |
| ) |
| print(f"\n==== forgetting check on {dataset}: {baseline} -> {adapter} ====") |
| print(delta_report(old, new)) |
|
|