File size: 6,482 Bytes
22de727
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
"""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()