MiniWeb-demo / src /miniweb /scripts /grader_audit_report.py
sparklabuofu's picture
Update the demo
22de727 verified
Raw History Blame Contribute Delete
6.48 kB
"""Score the verifier and the VLM judge against the grader-audit labels (src/miniweb/scripts/build_grader_audit.py).
A human task label is pass when every primitive is labeled pass, fail when any is labeled fail, unsure otherwise;
unsure labels are left out. Task-level accuracy is estimated for the full population of judged RQ1 episodes by
weighting each stratum (both pass, both fail, judge only, verifier only) by its size there, with a stratified
bootstrap 95% interval. Primitive-level accuracy is reported per family, unweighted. Cases with two labelers give
the inter-labeler agreement and Cohen's kappa.
python src/miniweb/scripts/grader_audit_report.py --url https://<deployment> # token: MINIWEB_RECOVERY_TOKEN
python src/miniweb/scripts/grader_audit_report.py --labels labels.json [--primary <annotator>]
python src/miniweb/scripts/grader_audit_report.py --url ... --pull-only # back up the labels without scoring them
"""
import argparse, json, os, random, sys, urllib.request
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3] # the repository
sys.path.insert(0, str(ROOT / "src"))
from miniwebgen.viewer import FAMILIES # noqa: E402
OUT = ROOT / "data/grader_audit"
FAM = {m: f for f, ms in FAMILIES.items() for m in ms}
def fetch(url, token):
req = urllib.request.Request(url.rstrip("/") + "/recovery/grader_audit/labels", headers={"X-Recovery-Token": token})
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode())
def task_label(prims):
vals = set(prims.values())
return "fail" if "fail" in vals else "pass" if vals == {"pass"} else "unsure"
def kappa(a, b):
n = len(a)
if not n:
return float("nan"), float("nan")
po = sum(x == y for x, y in zip(a, b)) / n
pa, pb = sum(a) / n, sum(b) / n
pe = pa * pb + (1 - pa) * (1 - pb)
return po, (po - pe) / (1 - pe) if pe < 1 else float("nan")
def weighted_acc(rows, pop, grader):
"""Population accuracy of a grader: per-stratum accuracy weighted by the stratum's population share."""
by = defaultdict(list)
for r in rows:
by[r["stratum"]].append(r[grader] == r["human"])
tot = sum(pop[s] for s in by)
return sum(pop[s] / tot * sum(v) / len(v) for s, v in by.items()), {s: (sum(v), len(v)) for s, v in by.items()}
def bootstrap(rows, pop, grader, reps=2000, seed=0):
rng, by = random.Random(seed), defaultdict(list)
for r in rows:
by[r["stratum"]].append(r)
accs = []
for _ in range(reps):
sample = [x for s in by.values() for x in (rng.choice(s) for _ in s)]
accs.append(weighted_acc(sample, pop, grader)[0])
accs.sort()
return accs[int(0.025 * reps)], accs[int(0.975 * reps) - 1]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", help="deployment to fetch labels from (token in MINIWEB_RECOVERY_TOKEN)")
ap.add_argument("--labels", help="a labels JSON list instead (as /annotate/api/grader_audit/labels.json gives)")
ap.add_argument("--primary", help="labeler whose labels are scored (default: the one with most labels)")
ap.add_argument("--pull-only", action="store_true",
help="only save the labels to data/grader_audit/labels.json (no scores, so labeling stays blind)")
args = ap.parse_args()
key = json.loads((OUT / "key.json").read_text())
pop, cases = key["population"], key["cases"]
labels = fetch(args.url, os.environ.get("MINIWEB_RECOVERY_TOKEN", "")) if args.url else json.load(open(args.labels))
(OUT / "labels.json").write_text(json.dumps(labels, indent=1, ensure_ascii=False))
if args.pull_only:
print(f"{len(labels)} labels by {sorted({r['labeler'] for r in labels})} -> {OUT / 'labels.json'}")
return
by_labeler = defaultdict(dict)
for r in labels:
if r["case_id"] in cases:
by_labeler[r["labeler"]][r["case_id"]] = r
if not by_labeler:
sys.exit("no labels yet")
primary = args.primary or max(by_labeler, key=lambda k: len(by_labeler[k]))
lab = by_labeler[primary]
rep = {"primary": primary, "labelers": {k: len(v) for k, v in by_labeler.items()}, "n_cases": len(cases)}
tasks, inst = [], []
for cid, r in lab.items():
k = cases[cid]
h = task_label(r["primitives"])
if h != "unsure":
tasks.append({"stratum": k["stratum"], "human": h == "pass", "judge": k["judge"], "verifier": k["verifier"]})
for i, v in r["primitives"].items():
if v != "unsure":
inst.append({"family": FAM.get(i.split("#")[0], "?"), "human": v == "pass",
"judge": k["judge_instances"].get(i), "verifier": k["verifier_instances"].get(i)})
rep["tasks_scored"], rep["tasks_unsure"] = len(tasks), len(lab) - len(tasks)
for g in ("judge", "verifier"):
acc, per = weighted_acc(tasks, pop, g)
lo, hi = bootstrap(tasks, pop, g)
rep[f"{g}_task_accuracy"] = {"population_weighted": acc, "ci95": [lo, hi],
"per_stratum": {s: f"{a}/{n}" for s, (a, n) in per.items()}}
fam = defaultdict(lambda: [0, 0])
for x in inst:
if x[g] is not None:
fam[x["family"]][0] += x[g] == x["human"]
fam[x["family"]][1] += 1
rep[f"{g}_primitive_accuracy"] = {f: f"{a}/{n} ({100 * a / n:.1f}%)" for f, (a, n) in sorted(fam.items())}
# who is right on the disagreements: the share of each disagreement stratum the human sides with the judge
for s in ("judge_only", "verifier_only"):
rows = [t for t in tasks if t["stratum"] == s]
rep[f"{s}_human_pass"] = f"{sum(t['human'] for t in rows)}/{len(rows)}"
others = [k for k in by_labeler if k != primary]
agree = {}
for o in others:
a, b = [], []
for cid in set(lab) & set(by_labeler[o]):
for i, v in lab[cid]["primitives"].items():
w = by_labeler[o][cid]["primitives"].get(i)
if "unsure" not in (v, w) and w is not None:
a.append(v == "pass")
b.append(w == "pass")
po, kp = kappa(a, b)
agree[o] = {"primitives": len(a), "agree": po, "kappa": kp}
rep["inter_labeler"] = agree
(OUT / "report.json").write_text(json.dumps(rep, indent=1))
print(json.dumps(rep, indent=1))
if __name__ == "__main__":
main()