File size: 5,801 Bytes
7a55d0d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Run the grader over the labelled dataset LOCALLY and write benchmark_results.json.

The dataset is confidential and never leaves this machine except as pseudonymised
transcripts sent to the LLM. The public results file holds only batch IDs, labels,
predictions and scores - no quotes, names or evidence text.

usage: python scripts/benchmark.py <dataset_root> [--only B482] [--video] [--rejudge]
"""
from __future__ import annotations

import argparse
import json
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))

import anthropic  # noqa: E402
from dotenv import find_dotenv, load_dotenv  # noqa: E402

from grader import judge as J  # noqa: E402
from grader import score as S  # noqa: E402
from grader import transcript as T  # noqa: E402

CACHE = ROOT / ".cache" / "benchmark"


def pair_key(name: str) -> str:
    """Zoom names the .vtt differently from the .mp4 (no date, different spacing),
    so pair on the batch ID, or 'demo <language>' for the demo folder."""
    toks = Path(name).stem.lower().replace("-", " ").split()
    return " ".join(toks[:2]) if toks[0] == "demo" else toks[0]


def find_pairs(root: Path) -> list[dict]:
    pairs = []
    for folder in sorted(p for p in root.iterdir() if p.is_dir()):
        vtts = {pair_key(f.name): f for f in folder.glob("*.vtt")}
        for mp4 in sorted(folder.glob("*.mp4")):
            key = pair_key(mp4.name)
            pairs.append({
                "id": f"{folder.name}/{key.upper() if key[0] == 'b' else key}",
                "batch": key.upper() if key[0] == "b" else key,
                "label": S.LABELS.get(folder.name),  # None for demo recordings
                "mp4": mp4, "vtt": vtts.get(key),
            })
    return pairs


def run_one(p: dict, client, rejudge: bool, video: bool) -> dict:
    slug = p["id"].replace("/", "__").replace(" ", "_")
    cache_file = CACHE / J.PROMPT_VERSION / f"{slug}.json"
    tr = T.load(p["vtt"].read_text(encoding="utf-8"))
    m = T.metrics(tr)
    media = None
    if video:
        from grader import media as M
        media = M.analyse(str(p["mp4"]), tr)
    fresh = not (cache_file.exists() and not rejudge)
    if not fresh:
        cached = json.loads(cache_file.read_text(encoding="utf-8"))
        jd, usage = J.Judgement.model_validate(cached["judgement"]), cached["usage"]
    else:
        jd, usage = J.judge(T.render(tr), m, client=client)  # audio is informational, not judged
        cache_file.write_text(json.dumps({"judgement": jd.model_dump(), "usage": usage}, indent=1),
                              encoding="utf-8")
    pillars = {k: getattr(jd, k).score for k in J.PILLARS}
    visual = (media or {}).get("visual_score")
    result = S.grade(pillars, m.get("english_ratio"), jd.confidence, visual)
    pillars["visual_compliance"] = visual
    return {"id": p["id"], "batch": p["batch"], "label": p["label"], "predicted": result["grade"],
            "llm_holistic": jd.holistic_grade, "weighted_score": result["weighted_score"],
            "confidence": result["confidence"], "pillars": pillars,
            "duration_min": m.get("duration_min"), "english_ratio": m.get("english_ratio"),
            "visual": {k: v for k, v in (media or {}).items() if k not in ("audio", "timeline")} or None,
            "tokens": usage if fresh else {"input_tokens": 0, "output_tokens": 0}}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("root", type=Path)
    ap.add_argument("--only", help="substring of the id to run")
    ap.add_argument("--video", action="store_true", help="also run video/audio analysis")
    ap.add_argument("--rejudge", action="store_true", help="ignore cached LLM judgements")
    ap.add_argument("--workers", type=int, default=2)
    args = ap.parse_args()

    load_dotenv(find_dotenv(usecwd=True))
    (CACHE / J.PROMPT_VERSION).mkdir(parents=True, exist_ok=True)
    client = anthropic.Anthropic(max_retries=6)
    pairs = [p for p in find_pairs(args.root) if p["vtt"] and (not args.only or args.only in p["id"])]

    with ThreadPoolExecutor(args.workers) as ex:
        rows = list(ex.map(lambda p: run_one(p, client, args.rejudge, args.video), pairs))

    order = {g: i for i, g in enumerate(S.GRADES)}
    labelled = [r for r in rows if r["label"]]
    exact = sum(r["predicted"] == r["label"] for r in labelled)
    within1 = sum(abs(order[r["predicted"]] - order[r["label"]]) <= 1 for r in labelled)
    holistic = sum(r["llm_holistic"] == r["label"] for r in labelled)

    print(f"{'id':28} {'actual':10} {'pred':10} {'llm':10} {'score':>5}  pillars")
    for r in rows:
        ps = " ".join(f"{v if v is not None else '-'}" for v in r["pillars"].values())
        print(f"{r['id']:28} {str(r['label']):10} {r['predicted']:10} {r['llm_holistic']:10} "
              f"{r['weighted_score']:>5}  {ps}")
    if labelled:
        print(f"\nexact {exact}/{len(labelled)} | within one grade {within1}/{len(labelled)} | "
              f"LLM holistic exact {holistic}/{len(labelled)}")
    tok = sum(r["tokens"]["input_tokens"] for r in rows), sum(r["tokens"]["output_tokens"] for r in rows)
    print(f"tokens in/out: {tok[0]}/{tok[1]}  (~${tok[0] * 2e-6 + tok[1] * 10e-6:.2f} on Sonnet 5.5)")

    if not args.only:
        public = [{k: v for k, v in r.items() if k != "tokens"} for r in rows]
        summary = {"prompt_version": J.PROMPT_VERSION, "model": J.MODEL,
                   "n_labelled": len(labelled), "exact": exact, "within_one": within1,
                   "llm_holistic_exact": holistic}
        (ROOT / "benchmark_results.json").write_text(
            json.dumps({"summary": summary, "rows": public}, indent=1), encoding="utf-8")


if __name__ == "__main__":
    main()