Download code/aggregate.py from adeerkhan/msq-plurality-gap: direct link, hf CLI and curl.
- Browser
- Download file 24.1 kB
-
https://huggingface.co/adeerkhan/msq-plurality-gap/resolve/main/code/aggregate.py
- Command line
-
hf download hf://adeerkhan/msq-plurality-gap/code/aggregate.py
-
curl -L -o aggregate.py https://huggingface.co/adeerkhan/msq-plurality-gap/resolve/main/code/aggregate.py
24.1 kB
| #!/usr/bin/env python3 | |
| """ | |
| Aggregate raw MSQ probe output into the paper's metrics, tables and figures. | |
| Headline metrics | |
| ---------------- | |
| PRR Plurality Recognition Rate | |
| On contested items, how often does the model choose the "the scholars | |
| are divided" option when the options are presented without any school | |
| label? High PRR = the model sees the dispute. | |
| MSQ Madhhab Sensitivity Quotient | |
| On contested items, how often does the model choose the position of the | |
| *named* school when a school is named explicitly? High MSQ = the model | |
| can produce a non-default position on request. | |
| BLIND-SPOT GAP = PRR(divided) when nothing is named vs MSQ when it is. | |
| A large gap means the model holds the competing positions but does not | |
| surface them: it can represent the plurality, but will not volunteer it. | |
| DISC Free-text disclosure rate -- fraction of answers that signal that the | |
| question depends on an unstated context variable. | |
| CONF Confidence calibration -- mean probability the model puts on its own | |
| chosen option, contested items versus uncontested controls. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import difflib | |
| import json | |
| import pathlib | |
| import re | |
| import statistics | |
| import sys | |
| from collections import Counter, defaultdict | |
| sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) | |
| import msq # noqa: E402 | |
| ROOT = pathlib.Path(__file__).resolve().parent.parent | |
| RAW = ROOT / "results" / "raw" | |
| OUTT = ROOT / "results" | |
| STOP = set("""the a an of is are was were be been being to in on at for from by with | |
| and or not no it its this that these those as if then than so such their they them | |
| there here which who whom what when where how do does did can could may might will | |
| would shall should must have has had you your i me my we our us he she his her | |
| but also about into over under more most some any all one two per each other | |
| according school position scholars view views ruling rule question answer""".split()) | |
| def content_tokens(s: str) -> list[str]: | |
| return [w for w in re.findall(r"[a-z0-9']+", (s or "").lower()) | |
| if w not in STOP and len(w) > 2] | |
| def content_sim(a: str, b: str) -> float: | |
| """Token F1 over content words -- how much of a ground-truth position the | |
| answer actually contains.""" | |
| ta, tb = content_tokens(a), content_tokens(b) | |
| if not ta or not tb: | |
| return 0.0 | |
| ca, cb = Counter(ta), Counter(tb) | |
| inter = sum((ca & cb).values()) | |
| if inter == 0: | |
| return 0.0 | |
| p, r = inter / len(ta), inter / len(tb) | |
| return 2 * p * r / (p + r) | |
| def names(text: str, name: str) -> bool: | |
| """Does the answer name this school / version / jurisdiction?""" | |
| if not name: | |
| return False | |
| norm = re.sub(r"[^a-z0-9]", "", (text or "").lower()) | |
| target = re.sub(r"[^a-z0-9]", "", name.lower()) | |
| if not target: | |
| return False | |
| if target in norm: | |
| return True | |
| # 'shafi'i' -> 'shafii', 'python 2' -> 'python2' | |
| alt = target.replace("i", "").replace("python", "") | |
| if alt and alt in norm and len(alt) >= 4: | |
| return True | |
| return False | |
| def load_raw(model: str) -> list[dict]: | |
| p = RAW / f"{model}.jsonl" | |
| if not p.exists(): | |
| return [] | |
| with p.open(encoding="utf-8") as fh: | |
| return [json.loads(l) for l in fh if l.strip()] | |
| def mean(xs): | |
| xs = [x for x in xs if x is not None] | |
| return round(statistics.fmean(xs), 4) if xs else None | |
| def rate(xs): | |
| xs = [x for x in xs if x is not None] | |
| return round(sum(xs) / len(xs), 4) if xs else None | |
| # ------------------------------------------------------------------ metrics -- | |
| def free_text_metrics(records: list[dict]) -> dict: | |
| rows_by_probe = defaultdict(list) | |
| for r in records: | |
| if r["probe"] in ("ft_default", "ft_specified", "ft_technical", | |
| "ft_repair", "ft_two_schools", "ft_pair"): | |
| body, _ = msq.strip_think(r.get("answer") or "") | |
| schools = msq.mentions_schools(body) | |
| rows_by_probe[r["probe"]].append(dict( | |
| cls=r["cls"], | |
| disclosure=msq.has_disclosure(body), | |
| schools=len(schools), | |
| clarify=msq.has_clarification(body), | |
| hedge=msq.has_hedge(body), | |
| refusal=msq.has_refusal(body), | |
| words=len(body.split()), | |
| logprob=r.get("mean_logprob"), | |
| )) | |
| out = {} | |
| for probe, rows in rows_by_probe.items(): | |
| per_cls = defaultdict(list) | |
| for row in rows: | |
| per_cls[row["cls"]].append(row) | |
| def agg_of(rs): | |
| return dict( | |
| n=len(rs), | |
| disclosure_rate=rate([r["disclosure"] for r in rs]), | |
| school_attribution_rate=rate([r["schools"] >= 1 for r in rs]), | |
| plural_attribution_rate=rate([r["schools"] >= 2 for r in rs]), | |
| clarification_rate=rate([r["clarify"] for r in rs]), | |
| hedge_rate=rate([r["hedge"] for r in rs]), | |
| refusal_rate=rate([r["refusal"] for r in rs]), | |
| mean_words=mean([r["words"] for r in rs]), | |
| mean_logprob=mean([r["logprob"] for r in rs]), | |
| ) | |
| agg = {cls: agg_of(rs) for cls, rs in per_cls.items()} | |
| agg["ALL"] = agg_of(rows) | |
| out[probe] = agg | |
| return out | |
| def mcq_metrics(records: list[dict]) -> dict: | |
| out = {} | |
| for probe in ("mcq_neutral", "mcq_specified"): | |
| rs = [r for r in records if r["probe"] == probe] | |
| if not rs: | |
| continue | |
| per_cls = defaultdict(list) | |
| for r in rs: | |
| gold = r["gold_index"] | |
| arg = r["argmax_index"] | |
| divided = r.get("divided_index") | |
| if divided is None: | |
| divided = max((i for i, o in enumerate(r["options"]) | |
| if o.lower().startswith(("the scholars are divided", | |
| "this is a contested matter", | |
| "it depends on the jurisdiction"))), | |
| default=None) | |
| per_cls[r["cls"]].append(dict( | |
| gold=gold, arg=arg, divided=divided, | |
| max_prob=r["max_prob"], | |
| entropy=msq.entropy(r["probs"]), | |
| probs=r["probs"], | |
| picked_divided=(divided is not None and arg == divided), | |
| divided_mass=(r["probs"][divided] if divided is not None else None), | |
| correct=(arg == gold), | |
| schools_labeled=any("(" in o and ")" in o for o in r["options"]), | |
| )) | |
| agg = {} | |
| for cls, rows in per_cls.items(): | |
| contested = cls in ("A", "B", "C", "E") | |
| agg[cls] = dict( | |
| n=len(rows), | |
| plurality_recognition_rate=rate([r["picked_divided"] for r in rows]) | |
| if contested or cls == "C" else None, | |
| plurality_mass=mean([r["divided_mass"] for r in rows]) | |
| if contested or cls == "C" else None, | |
| school_adherence_rate=rate([r["correct"] for r in rows]) | |
| if probe == "mcq_specified" else None, | |
| accuracy=rate([r["correct"] for r in rows]) | |
| if cls == "D" else None, | |
| mean_max_prob=mean([r["max_prob"] for r in rows]), | |
| mean_entropy=mean([r["entropy"] for r in rows]), | |
| argmax_hist=defaultdict(int, { | |
| k: sum(1 for r in rows if r["arg"] == k) | |
| for k in set(r["arg"] for r in rows)}), | |
| ) | |
| agg[cls]["argmax_hist"] = dict(sorted(agg[cls]["argmax_hist"].items())) | |
| agg["ALL"] = dict( | |
| n=sum(len(v) for v in per_cls.values()), | |
| plurality_recognition_rate=rate( | |
| [r["picked_divided"] for rows in per_cls.values() for r in rows]), | |
| plurality_mass=mean( | |
| [r["divided_mass"] for rows in per_cls.values() for r in rows]), | |
| school_adherence_rate=rate( | |
| [r["correct"] for rows in per_cls.values() for r in rows]) | |
| if probe == "mcq_specified" else None, | |
| mean_max_prob=mean([r["max_prob"] for rows in per_cls.values() for r in rows]), | |
| mean_entropy=mean([r["entropy"] for rows in per_cls.values() for r in rows]), | |
| ) | |
| out[probe] = agg | |
| return out | |
| def positions_contained(records: list[dict], threshold: float = 0.04) -> dict: | |
| """How many of the competing positions does an answer actually carry? | |
| Scored in the model's own representation space (cosine similarity of the | |
| answer to each ground-truth position, computed at run time). We report a | |
| threshold-free version -- the gap between the best-matching position and | |
| the second-best -- because a large gap means the answer has collapsed onto | |
| a single position, and a small one means it carries two. | |
| """ | |
| by_probe = defaultdict(list) | |
| for r in records: | |
| if not r["probe"].startswith("ft_"): | |
| continue | |
| sims = r.get("position_sims") | |
| if not sims: | |
| continue | |
| s = sorted(sims, reverse=True) | |
| top1 = s[0] | |
| top2 = s[1] if len(s) > 1 else 0.0 | |
| by_probe[r["probe"]].append(dict( | |
| cls=r["cls"], n_positions=sum(1 for x in sims if x >= top1 - threshold), | |
| gap=round(top1 - top2, 4), top1=round(top1, 4), top2=round(top2, 4))) | |
| out = {} | |
| for probe, rows in by_probe.items(): | |
| per_cls = defaultdict(list) | |
| for row in rows: | |
| per_cls[row["cls"]].append(row) | |
| def agg(rs): | |
| return dict(n=len(rs), | |
| mean_positions_contained=mean([r["n_positions"] for r in rs]), | |
| mean_top1=mean([r["top1"] for r in rs]), | |
| mean_top2=mean([r["top2"] for r in rs]), | |
| mean_gap=mean([r["gap"] for r in rs])) | |
| out[probe] = {"ALL": agg(rows), | |
| **{cls: agg(rs) for cls, rs in per_cls.items()}} | |
| return out | |
| def behaviour_matrix(records: list[dict]) -> dict: | |
| """The decisive 2x2: does the model have the default right, and does it | |
| respond to the stated context? | |
| default correct + switch correct -> knows both, discloses neither | |
| default correct + switch wrong -> has one answer and ignores context | |
| default wrong + switch correct -> knows the minority, will not volunteer | |
| default wrong + switch wrong -> knows neither | |
| """ | |
| items = {i["id"]: i for i in msq.load_items()} | |
| neu = {r["item_id"]: r for r in records if r["probe"] == "mcq_neutral"} | |
| spec = {r["item_id"]: r for r in records if r["probe"] == "mcq_specified"} | |
| rows = [] | |
| for iid, it in items.items(): | |
| if iid not in neu or iid not in spec: | |
| continue | |
| n, s = neu[iid], spec[iid] | |
| # which ground-truth position did the neutral answer pick? | |
| default_positions = [p for p in it["positions"] | |
| if set(p["schools"]) & set(it["majority_schools"])] | |
| default_txts = {p["txt"] for p in default_positions} | |
| picked = n["options"][n["argmax_index"]] if n["argmax_index"] < len(n["options"]) else "" | |
| default_correct = (picked in default_txts) if default_txts else None | |
| switch_correct = (s["argmax_index"] == s["gold_index"]) | |
| rows.append(dict(item_id=iid, cls=it["cls"], | |
| default_correct=default_correct, | |
| switch_correct=switch_correct)) | |
| per_cls = defaultdict(list) | |
| for row in rows: | |
| per_cls[row["cls"]].append(row) | |
| def classify(rs): | |
| c = Counter() | |
| for r in rs: | |
| if r["default_correct"] is None: | |
| continue | |
| if r["default_correct"] and r["switch_correct"]: | |
| c["both"] += 1 | |
| elif r["default_correct"] and not r["switch_correct"]: | |
| c["default_only"] += 1 | |
| elif not r["default_correct"] and r["switch_correct"]: | |
| c["switch_only"] += 1 | |
| else: | |
| c["neither"] += 1 | |
| n = sum(c.values()) | |
| return dict(n=n, **{k: round(v / n, 3) for k, v in c.items()} if n else {}) | |
| out = {cls: classify(rs) for cls, rs in per_cls.items()} | |
| out["ALL"] = classify(rows) | |
| out["_rows"] = rows | |
| return out | |
| def context_response(records: list[dict]) -> dict: | |
| """How much does naming the context actually move the answer? | |
| The neutral and specified conditions present byte-identical options in the | |
| same order, so the two probability distributions are directly comparable. | |
| We report the total-variation distance between them, and the change in the | |
| probability mass on the position of the context that was named. | |
| TV = 0 means the instruction had *no effect whatsoever* on the answer. | |
| """ | |
| items = {i["id"]: i for i in msq.load_items()} | |
| neu = {r["item_id"]: r for r in records if r["probe"] == "mcq_neutral"} | |
| spec = {r["item_id"]: r for r in records if r["probe"] == "mcq_specified"} | |
| rows = [] | |
| for iid, it in items.items(): | |
| if iid not in neu or iid not in spec: | |
| continue | |
| n, s = neu[iid], spec[iid] | |
| pn, ps = n["probs"], s["probs"] | |
| if len(pn) != len(ps): | |
| continue | |
| tv = 0.5 * sum(abs(a - b) for a, b in zip(pn, ps)) | |
| # index of the named context's position among the options | |
| named_idx = None | |
| opts = msq._options_shared(it) | |
| for i, o in enumerate(opts): | |
| if it.get("minority_school") and it["minority_school"] in o["schools"]: | |
| named_idx = i | |
| break | |
| if named_idx is None: | |
| named_idx = None | |
| rows.append(dict( | |
| item_id=iid, cls=it["cls"], | |
| tv=round(tv, 4), | |
| argmax_unchanged=(n["argmax_index"] == s["argmax_index"]), | |
| p_named_before=round(pn[named_idx], 4) if named_idx is not None else None, | |
| p_named_after=round(ps[named_idx], 4) if named_idx is not None else None, | |
| delta=round(ps[named_idx] - pn[named_idx], 4) if named_idx is not None else None, | |
| )) | |
| per_cls = defaultdict(list) | |
| for row in rows: | |
| per_cls[row["cls"]].append(row) | |
| def agg(rs): | |
| ds = [r["delta"] for r in rs if r["delta"] is not None] | |
| return dict( | |
| n=len(rs), | |
| mean_context_response_tv=mean([r["tv"] for r in rs]), | |
| argmax_unchanged_rate=rate([r["argmax_unchanged"] for r in rs]), | |
| n_unchanged_distributions=sum(1 for r in rs if r["tv"] < 0.01), | |
| mean_delta_on_named_position=mean(ds), | |
| median_delta_on_named_position=( | |
| round(sorted(ds)[len(ds) // 2], 4) if ds else None), | |
| ) | |
| out = {cls: agg(rs) for cls, rs in per_cls.items()} | |
| out["ALL"] = agg(rows) | |
| out["_rows"] = rows | |
| return out | |
| def content_adherence(records: list[dict]) -> dict: | |
| """Does the model follow the *content* of the school it was told to follow? | |
| Uses the cosine similarity of the answer to each ground-truth position in | |
| the model's own representation space (computed at run time), because the | |
| model paraphrases heavily and a lexical overlap measure is far too brittle. | |
| """ | |
| items = {i["id"]: i for i in msq.load_items()} | |
| rows = [] | |
| for r in records: | |
| if r["probe"] != "ft_specified": | |
| continue | |
| it = items.get(r["item_id"]) | |
| if not it or not it.get("minority_school"): | |
| continue | |
| sims = r.get("position_sims") | |
| if not sims or len(sims) != len(it["positions"]): | |
| continue | |
| spec_i = next((i for i, p in enumerate(it["positions"]) | |
| if it["minority_school"] in p["schools"]), None) | |
| if spec_i is None: | |
| continue | |
| spec_sim = sims[spec_i] | |
| other = max([s for i, s in enumerate(sims) if i != spec_i], default=0.0) | |
| default_i = next((i for i, p in enumerate(it["positions"]) | |
| if set(p["schools"]) & set(it["majority_schools"])), None) | |
| default_sim = sims[default_i] if default_i is not None else None | |
| body, _ = msq.strip_think(r.get("answer") or "") | |
| rows.append(dict( | |
| item_id=r["item_id"], cls=r["cls"], | |
| names_context=names(body, it["minority_school"]), | |
| sim_specified=round(spec_sim, 4), | |
| sim_best_other=round(other, 4), | |
| sim_default=round(default_sim, 4) if default_sim is not None else None, | |
| split_ratio=(round(default_sim / (spec_sim + default_sim), 4) | |
| if default_sim is not None else None), | |
| adheres=spec_sim > other, | |
| label_adoption=(names(body, it["minority_school"]) and spec_sim <= other), | |
| )) | |
| per_cls = defaultdict(list) | |
| for row in rows: | |
| per_cls[row["cls"]].append(row) | |
| def agg(rs): | |
| return dict( | |
| n=len(rs), | |
| names_context_rate=rate([r["names_context"] for r in rs]), | |
| content_adherence_rate=rate([r["adheres"] for r in rs]), | |
| label_adoption_rate=rate([r["label_adoption"] for r in rs]), | |
| mean_sim_specified=mean([r["sim_specified"] for r in rs]), | |
| mean_sim_best_other=mean([r["sim_best_other"] for r in rs]), | |
| mean_split_ratio=mean([r["split_ratio"] for r in rs]), | |
| ) | |
| out = {cls: agg(rs) for cls, rs in per_cls.items()} | |
| out["ALL"] = agg(rows) | |
| out["_rows"] = rows | |
| return out | |
| def specified_outcome(records: list[dict]) -> dict: | |
| """Decompose what the model picks in the *specified* condition. | |
| Three possible moves: it picked the position of the school it was told to | |
| follow; it picked the 'the scholars are divided' option anyway; or it | |
| picked some other position. | |
| """ | |
| out = {} | |
| for probe in ("mcq_neutral", "mcq_specified"): | |
| rs = [r for r in records if r["probe"] == probe] | |
| if not rs: | |
| continue | |
| per_cls = defaultdict(list) | |
| for r in rs: | |
| if r["cls"] == "D": | |
| continue | |
| div = r.get("divided_index") | |
| arg = r["argmax_index"] | |
| if probe == "mcq_specified": | |
| bucket = ("named_school" if arg == r["gold_index"] | |
| else "divided_anyway" if div is not None and arg == div | |
| else "other_position") | |
| else: | |
| bucket = ("divided" if div is not None and arg == div | |
| else "a_position") | |
| per_cls[r["cls"]].append(bucket) | |
| res = {} | |
| for cls, buckets in per_cls.items(): | |
| c = Counter(buckets) | |
| n = sum(c.values()) | |
| res[cls] = dict(n=n, **{k: round(v / n, 3) for k, v in c.items()}) | |
| allb = [b for bs in per_cls.values() for b in bs] | |
| c = Counter(allb) | |
| n = sum(c.values()) | |
| res["ALL"] = dict(n=n, **{k: round(v / n, 3) for k, v in c.items()}) | |
| out[probe] = res | |
| return out | |
| def answer_change(records: list[dict]) -> dict: | |
| """How much does naming the school actually change the free-text answer?""" | |
| by_item = defaultdict(dict) | |
| for r in records: | |
| if r["probe"] in ("ft_default", "ft_specified"): | |
| body, _ = msq.strip_think(r["answer"] or "") | |
| by_item[r["item_id"]][r["probe"]] = body | |
| sims, changed = [], [] | |
| for iid, d in by_item.items(): | |
| if "ft_default" in d and "ft_specified" in d: | |
| s = difflib.SequenceMatcher(None, d["ft_default"], d["ft_specified"]).ratio() | |
| sims.append(s) | |
| changed.append(s < 0.60) | |
| return dict(n=len(sims), mean_similarity=mean(sims), | |
| changed_rate=rate(changed)) | |
| def model_report(model: str) -> dict: | |
| recs = load_raw(model) | |
| if not recs: | |
| return {} | |
| rep = {"model": model, "n_records": len(recs)} | |
| rep["mcq"] = mcq_metrics(recs) | |
| rep["free_text"] = free_text_metrics(recs) | |
| rep["answer_change"] = answer_change(recs) | |
| rep["content_adherence"] = content_adherence(recs) | |
| rep["positions_contained"] = positions_contained(recs) | |
| rep["behaviour_matrix"] = behaviour_matrix(recs) | |
| rep["specified_outcome"] = specified_outcome(recs) | |
| rep["context_response"] = context_response(recs) | |
| n = rep["mcq"].get("mcq_neutral", {}).get("ALL", {}) | |
| s = rep["mcq"].get("mcq_specified", {}).get("ALL", {}) | |
| rep["headline"] = { | |
| "PRR": n.get("plurality_recognition_rate"), | |
| "PRR_mass": n.get("plurality_mass"), | |
| "MSQ": s.get("school_adherence_rate"), | |
| "PRR_A": rep["mcq"].get("mcq_neutral", {}).get("A", {}).get("plurality_recognition_rate"), | |
| "PRR_B": rep["mcq"].get("mcq_neutral", {}).get("B", {}).get("plurality_recognition_rate"), | |
| "PRR_C": rep["mcq"].get("mcq_neutral", {}).get("C", {}).get("plurality_recognition_rate"), | |
| "PRR_E": rep["mcq"].get("mcq_neutral", {}).get("E", {}).get("plurality_recognition_rate"), | |
| "MSQ_A": rep["mcq"].get("mcq_specified", {}).get("A", {}).get("school_adherence_rate"), | |
| "MSQ_C": rep["mcq"].get("mcq_specified", {}).get("C", {}).get("school_adherence_rate"), | |
| "MSQ_E": rep["mcq"].get("mcq_specified", {}).get("E", {}).get("school_adherence_rate"), | |
| "acc_D": rep["mcq"].get("mcq_neutral", {}).get("D", {}).get("accuracy"), | |
| "conf_contested": n.get("mean_max_prob"), | |
| "conf_control": rep["mcq"].get("mcq_neutral", {}).get("D", {}).get("mean_max_prob"), | |
| "disc_default": rep["free_text"].get("ft_default", {}).get("ALL", {}).get("disclosure_rate"), | |
| "disc_technical": rep["free_text"].get("ft_technical", {}).get("ALL", {}).get("disclosure_rate"), | |
| "plural_attr_two_schools": rep["free_text"].get("ft_two_schools", {}).get("ALL", {}).get("plural_attribution_rate"), | |
| "names_context_rate": rep["content_adherence"].get("ALL", {}).get("names_context_rate"), | |
| "content_adherence_rate": rep["content_adherence"].get("ALL", {}).get("content_adherence_rate"), | |
| "label_adoption_rate": rep["content_adherence"].get("ALL", {}).get("label_adoption_rate"), | |
| "positions_default": rep["positions_contained"].get("ft_default", {}).get("ALL", {}).get("mean_positions_contained"), | |
| "positions_two_schools": rep["positions_contained"].get("ft_two_schools", {}).get("ALL", {}).get("mean_positions_contained"), | |
| "positions_pair": rep["positions_contained"].get("ft_pair", {}).get("ALL", {}).get("mean_positions_contained"), | |
| "positions_repair": rep["positions_contained"].get("ft_repair", {}).get("ALL", {}).get("mean_positions_contained"), | |
| "disc_repair": rep["free_text"].get("ft_repair", {}).get("ALL", {}).get("disclosure_rate"), | |
| "bm_both": rep["behaviour_matrix"].get("ALL", {}).get("both"), | |
| "bm_default_only": rep["behaviour_matrix"].get("ALL", {}).get("default_only"), | |
| "bm_switch_only": rep["behaviour_matrix"].get("ALL", {}).get("switch_only"), | |
| "bm_neither": rep["behaviour_matrix"].get("ALL", {}).get("neither"), | |
| } | |
| h = rep["headline"] | |
| if h["PRR"] is not None and h["MSQ"] is not None: | |
| h["blind_spot_gap"] = round(h["MSQ"] - h["PRR"], 4) | |
| return rep | |
| def main() -> None: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--models", nargs="*", default=None) | |
| args = ap.parse_args() | |
| models = args.models or sorted(p.stem for p in RAW.glob("*.jsonl")) | |
| reports = {} | |
| for m in models: | |
| r = model_report(m) | |
| if r: | |
| reports[m] = r | |
| print(f"{m}: PRR={r['headline'].get('PRR')} MSQ={r['headline'].get('MSQ')} " | |
| f"gap={r['headline'].get('blind_spot_gap')} " | |
| f"disc={r['headline'].get('disc_default')}") | |
| OUTT.mkdir(parents=True, exist_ok=True) | |
| (OUTT / "metrics.json").write_text(json.dumps(reports, indent=2, ensure_ascii=False)) | |
| print(f"\nwrote {OUTT / 'metrics.json'}") | |
| if __name__ == "__main__": | |
| main() | |