flakeval 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flakeval/__init__.py +5 -0
- flakeval/cli.py +99 -0
- flakeval/io.py +84 -0
- flakeval/models.py +68 -0
- flakeval/progress.py +101 -0
- flakeval/scoring.py +78 -0
- flakeval/stats.py +254 -0
- flakeval/study.py +311 -0
- flakeval/templates/my_eval.py +125 -0
- flakeval/templates/sample_data.csv +13 -0
- flakeval-0.1.0.dist-info/METADATA +559 -0
- flakeval-0.1.0.dist-info/RECORD +16 -0
- flakeval-0.1.0.dist-info/WHEEL +5 -0
- flakeval-0.1.0.dist-info/entry_points.txt +2 -0
- flakeval-0.1.0.dist-info/licenses/LICENSE +21 -0
- flakeval-0.1.0.dist-info/top_level.txt +1 -0
flakeval/__init__.py
ADDED
flakeval/cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Command line interface: `flakeval report`, `flakeval compare`, and `flakeval init` (copies the settings template)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .io import load_jsonl
|
|
12
|
+
from .stats import Comparison, Summary, compare, summarize
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_summary(name: str, s: Summary) -> str:
|
|
16
|
+
lines = [
|
|
17
|
+
f"== {name} ==",
|
|
18
|
+
f"items: {s.n_items} runs per item: {s.n_runs}",
|
|
19
|
+
f"mean score: {s.mean:.4f} 95% CI: [{s.ci[0]:.4f}, {s.ci[1]:.4f}]",
|
|
20
|
+
]
|
|
21
|
+
if s.n_runs > 1:
|
|
22
|
+
if s.run_noise_share != s.run_noise_share: # NaN: the scores did not vary at all
|
|
23
|
+
lines.append("uncertainty from run-to-run noise: n/a (the scores did not vary at all)")
|
|
24
|
+
else:
|
|
25
|
+
lines.append(f"uncertainty from run-to-run noise: {s.run_noise_share:.0%} (rest: eval-set sampling)")
|
|
26
|
+
n_flaky = round(s.flaky_fraction * s.n_items) # show the count: "0%" next to a listed unstable item looks like a bug
|
|
27
|
+
lines.append(f"flaky items (score changes between runs): {n_flaky} of {s.n_items} ({s.flaky_fraction:.1%})")
|
|
28
|
+
if s.flaky_items:
|
|
29
|
+
lines.append("most unstable items: " + ", ".join(f"{i} (sd {sd:.2f})" for i, sd in s.flaky_items[:5]))
|
|
30
|
+
lines += [f"WARNING: {w}" for w in s.warnings]
|
|
31
|
+
return "\n".join(lines)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def format_comparison(c: Comparison) -> str:
|
|
35
|
+
lines = [
|
|
36
|
+
f"A: {c.mean_a:.4f} ({c.runs_a} runs) B: {c.mean_b:.4f} ({c.runs_b} runs) items: {c.n_items}",
|
|
37
|
+
f"delta (B - A): {c.delta:+.4f} 95% CI: [{c.ci[0]:+.4f}, {c.ci[1]:+.4f}] p = {c.p_value:.4f}",
|
|
38
|
+
f"smallest reliably detectable difference: ~{c.mde:.4f}",
|
|
39
|
+
f"VERDICT: {c.verdict}",
|
|
40
|
+
c.advice,
|
|
41
|
+
]
|
|
42
|
+
lines += [f"WARNING: {w}" for w in c.warnings]
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def init_command(filename: str, force: bool = False, with_sample_data: bool = True) -> int:
|
|
47
|
+
"""Copy the settings template (and a tiny sample dataset) out of the installed package."""
|
|
48
|
+
templates = resources.files("flakeval") / "templates"
|
|
49
|
+
dest = Path(filename)
|
|
50
|
+
if dest.exists() and not force:
|
|
51
|
+
print(f"{dest} already exists, so nothing was changed. Use --force to overwrite it "
|
|
52
|
+
f"(you would lose your edits), or choose another name: flakeval init other_name.py")
|
|
53
|
+
return 1
|
|
54
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
dest.write_text((templates / "my_eval.py").read_text(encoding="utf-8"), encoding="utf-8")
|
|
56
|
+
print(f"created {dest}")
|
|
57
|
+
sample = dest.parent / "sample_data.csv"
|
|
58
|
+
if with_sample_data and not sample.exists():
|
|
59
|
+
sample.write_text((templates / "sample_data.csv").read_text(encoding="utf-8"), encoding="utf-8")
|
|
60
|
+
print(f"created {sample} (12 easy questions, just to try things out)")
|
|
61
|
+
print(f"\nNext:\n 1. open {dest} and edit the settings at the top (data path, models, how to score)\n"
|
|
62
|
+
f" 2. python {dest} --dry-run # tries 2 questions, saves nothing\n"
|
|
63
|
+
f" 3. python {dest} # the real run")
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv: list[str] | None = None) -> int:
|
|
68
|
+
p = argparse.ArgumentParser(prog="flakeval", description="Is your eval improvement real, or just noise?")
|
|
69
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
70
|
+
r = sub.add_parser("report", help="noise report for one system")
|
|
71
|
+
r.add_argument("results")
|
|
72
|
+
c = sub.add_parser("compare", help="paired comparison of system A vs B")
|
|
73
|
+
c.add_argument("a")
|
|
74
|
+
c.add_argument("b")
|
|
75
|
+
i = sub.add_parser("init", help="create a settings file (my_eval.py) to test your own models and data")
|
|
76
|
+
i.add_argument("filename", nargs="?", default="my_eval.py")
|
|
77
|
+
i.add_argument("--force", action="store_true", help="overwrite the file if it exists")
|
|
78
|
+
i.add_argument("--no-sample-data", action="store_true", help="do not also create sample_data.csv")
|
|
79
|
+
for sp in (r, c):
|
|
80
|
+
sp.add_argument("--json", action="store_true", help="machine-readable output")
|
|
81
|
+
sp.add_argument("--seed", type=int, default=0)
|
|
82
|
+
args = p.parse_args(argv)
|
|
83
|
+
|
|
84
|
+
if args.cmd == "init":
|
|
85
|
+
return init_command(args.filename, args.force, not args.no_sample_data)
|
|
86
|
+
if args.cmd == "report":
|
|
87
|
+
s = summarize(load_jsonl(args.results), seed=args.seed)
|
|
88
|
+
print(json.dumps(dataclasses.asdict(s), indent=2) if args.json else format_summary(args.results, s))
|
|
89
|
+
else:
|
|
90
|
+
res = compare(load_jsonl(args.a), load_jsonl(args.b), seed=args.seed)
|
|
91
|
+
print(json.dumps(dataclasses.asdict(res), indent=2) if args.json else format_comparison(res))
|
|
92
|
+
# non-zero exit on regression => usable as a CI gate
|
|
93
|
+
if res.verdict == "REAL REGRESSION":
|
|
94
|
+
return 1
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
sys.exit(main())
|
flakeval/io.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Reading/writing results and the `repeat` helper that runs an eval many times.
|
|
2
|
+
|
|
3
|
+
Results format (JSONL, one line per item per run):
|
|
4
|
+
{"item_id": "q17", "run": 2, "score": 1.0}
|
|
5
|
+
`correct` (bool) is accepted in place of `score`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Iterable
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .stats import Scores
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_jsonl(path: str | Path) -> Scores:
|
|
21
|
+
by_item: dict[str, dict[int, float]] = defaultdict(dict)
|
|
22
|
+
with open(path, encoding="utf-8") as f:
|
|
23
|
+
for line_no, line in enumerate(f, 1):
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if not line:
|
|
26
|
+
continue
|
|
27
|
+
row = json.loads(line)
|
|
28
|
+
try:
|
|
29
|
+
item = str(row["item_id"])
|
|
30
|
+
run = int(row.get("run", len(by_item[item])))
|
|
31
|
+
score = float(row["score"] if "score" in row else row["correct"])
|
|
32
|
+
except KeyError as e:
|
|
33
|
+
raise ValueError(f"{path}:{line_no}: missing field {e}") from None
|
|
34
|
+
by_item[item][run] = score
|
|
35
|
+
if not by_item:
|
|
36
|
+
raise ValueError(f"{path}: no results found")
|
|
37
|
+
k = min(len(r) for r in by_item.values())
|
|
38
|
+
ids = sorted(by_item)
|
|
39
|
+
x = np.array([[by_item[i][r] for r in sorted(by_item[i])[:k]] for i in ids])
|
|
40
|
+
return Scores(ids, x)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_jsonl(scores: Scores, path: str | Path) -> None:
|
|
44
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
46
|
+
for i, item in enumerate(scores.ids):
|
|
47
|
+
for r in range(scores.n_runs):
|
|
48
|
+
f.write(json.dumps({"item_id": item, "run": r, "score": float(scores.x[i, r])}) + "\n")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def repeat(
|
|
52
|
+
fn: Callable[[Any], float],
|
|
53
|
+
items: Iterable[Any] | dict[str, Any],
|
|
54
|
+
runs: int = 5,
|
|
55
|
+
workers: int = 1,
|
|
56
|
+
save: str | Path | None = None,
|
|
57
|
+
with_run: bool = False,
|
|
58
|
+
) -> Scores:
|
|
59
|
+
"""Run `fn(item) -> score` `runs` times for every item.
|
|
60
|
+
|
|
61
|
+
`items` may be a dict {item_id: input} or a plain iterable (ids become "0", "1", ...).
|
|
62
|
+
Use workers > 1 for API-bound evals (threads).
|
|
63
|
+
With with_run=True, fn is called as fn(item, run_index), e.g. to derive a different
|
|
64
|
+
sampling seed for every run.
|
|
65
|
+
"""
|
|
66
|
+
pairs = list(items.items()) if isinstance(items, dict) else [(str(i), x) for i, x in enumerate(items)]
|
|
67
|
+
jobs = [(i, r) for i in range(len(pairs)) for r in range(runs)]
|
|
68
|
+
out = np.zeros((len(pairs), runs))
|
|
69
|
+
|
|
70
|
+
def work(job: tuple[int, int]) -> tuple[int, int, float]:
|
|
71
|
+
i, r = job
|
|
72
|
+
return i, r, float(fn(pairs[i][1], r) if with_run else fn(pairs[i][1]))
|
|
73
|
+
|
|
74
|
+
if workers <= 1:
|
|
75
|
+
results = map(work, jobs)
|
|
76
|
+
else:
|
|
77
|
+
pool = ThreadPoolExecutor(max_workers=workers)
|
|
78
|
+
results = pool.map(work, jobs)
|
|
79
|
+
for i, r, v in results:
|
|
80
|
+
out[i, r] = v
|
|
81
|
+
scores = Scores([p[0] for p in pairs], out)
|
|
82
|
+
if save:
|
|
83
|
+
save_jsonl(scores, save)
|
|
84
|
+
return scores
|
flakeval/models.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Talking to a model: per-call random seeds, and a caller for any OpenAI-compatible chat endpoint.
|
|
2
|
+
|
|
3
|
+
A system can instead supply its own Python function (key "call"), so any other SDK or local model works too.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
import zlib
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def call_seed(base_seed: int, item_id: str, run: int) -> int:
|
|
16
|
+
"""A different seed for every (question, run) so repeated runs genuinely differ, yet the study is reproducible."""
|
|
17
|
+
return zlib.crc32(f"{base_seed}|{item_id}|{run}".encode()) & 0x7FFFFFFF
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_caller(system: dict, max_tokens: int):
|
|
21
|
+
"""Returns f(prompt, system_prompt, temperature, seed) -> answer text."""
|
|
22
|
+
if system.get("call"):
|
|
23
|
+
user_fn = system["call"]
|
|
24
|
+
return lambda prompt, system_prompt, temperature, seed: user_fn(
|
|
25
|
+
prompt=prompt, system_prompt=system_prompt, temperature=temperature, seed=seed, max_tokens=max_tokens)
|
|
26
|
+
|
|
27
|
+
for key in ("model", "base_url"):
|
|
28
|
+
if not system.get(key):
|
|
29
|
+
raise SystemExit(f"System {system.get('name')!r} needs {key!r} (or a 'call' function).")
|
|
30
|
+
api_key = None
|
|
31
|
+
if system.get("api_key_env"):
|
|
32
|
+
api_key = os.environ.get(system["api_key_env"])
|
|
33
|
+
if not api_key:
|
|
34
|
+
raise SystemExit(f"Environment variable {system['api_key_env']} is not set (needed for system "
|
|
35
|
+
f"{system['name']!r}). Set it in your terminal before running.")
|
|
36
|
+
url = system["base_url"].rstrip("/") + "/chat/completions"
|
|
37
|
+
limit_param = system.get("token_limit_param", "max_tokens")
|
|
38
|
+
|
|
39
|
+
def caller(prompt, system_prompt, temperature, seed):
|
|
40
|
+
messages = ([{"role": "system", "content": system_prompt}] if system_prompt else []) + \
|
|
41
|
+
[{"role": "user", "content": prompt}]
|
|
42
|
+
body = {"model": system["model"], "messages": messages, limit_param: max_tokens}
|
|
43
|
+
if temperature is not None:
|
|
44
|
+
body["temperature"] = temperature
|
|
45
|
+
if system.get("send_seed", True):
|
|
46
|
+
body["seed"] = seed
|
|
47
|
+
headers = {"Content-Type": "application/json"}
|
|
48
|
+
if api_key:
|
|
49
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
50
|
+
req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers)
|
|
51
|
+
last_error = None
|
|
52
|
+
for attempt in range(4): # retry busy/overloaded servers; fail loudly on real errors
|
|
53
|
+
try:
|
|
54
|
+
with urllib.request.urlopen(req, timeout=180) as resp:
|
|
55
|
+
return json.loads(resp.read())["choices"][0]["message"]["content"] or ""
|
|
56
|
+
except urllib.error.HTTPError as e:
|
|
57
|
+
if e.code in (429, 500, 502, 503, 504):
|
|
58
|
+
last_error = f"HTTP {e.code}"
|
|
59
|
+
else:
|
|
60
|
+
detail = e.read().decode("utf-8", "replace")[:300]
|
|
61
|
+
raise RuntimeError(f"{system['name']}: server answered HTTP {e.code} ({detail}). Check model name, "
|
|
62
|
+
f"base_url and API key.") from None
|
|
63
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
|
64
|
+
last_error = str(e)
|
|
65
|
+
time.sleep(2 ** attempt)
|
|
66
|
+
raise RuntimeError(f"{system['name']}: gave up after 4 attempts ({last_error}). Is the server running?")
|
|
67
|
+
|
|
68
|
+
return caller
|
flakeval/progress.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The progress display shown while a study runs: one overall bar that counts model calls."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def hms(seconds: float) -> str:
|
|
11
|
+
seconds = int(max(seconds, 0))
|
|
12
|
+
return f"{seconds // 3600}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Progress:
|
|
16
|
+
"""One overall bar that counts model calls, with the current system shown next to it.
|
|
17
|
+
|
|
18
|
+
In a terminal it redraws a single line. When the output goes to a file or pipe (for example `| tee run.log`)
|
|
19
|
+
it prints one plain line every 10% instead, so logs do not fill up with redraw characters."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, total: int, stream=None):
|
|
22
|
+
self.total, self.done = max(total, 1), 0
|
|
23
|
+
self.stream = stream or sys.stdout
|
|
24
|
+
self.tty = hasattr(self.stream, "isatty") and self.stream.isatty()
|
|
25
|
+
self.start = time.monotonic()
|
|
26
|
+
self.system, self.sys_index, self.sys_count, self.sys_calls, self.sys_done = "", 0, 0, 0, 0
|
|
27
|
+
self._last_draw, self._next_mark, self._drawn = 0.0, 10, False
|
|
28
|
+
self._lock = threading.RLock()
|
|
29
|
+
|
|
30
|
+
def start_system(self, name: str, index: int, count: int, calls: int) -> None:
|
|
31
|
+
with self._lock:
|
|
32
|
+
self.system, self.sys_index, self.sys_count, self.sys_calls, self.sys_done = name, index, count, calls, 0
|
|
33
|
+
self._update(force=True)
|
|
34
|
+
|
|
35
|
+
def advance(self) -> None:
|
|
36
|
+
with self._lock:
|
|
37
|
+
self.done += 1
|
|
38
|
+
self.sys_done += 1
|
|
39
|
+
self._update(force=self.done >= self.total)
|
|
40
|
+
|
|
41
|
+
def message(self, text: str) -> None:
|
|
42
|
+
"""Print a normal line without leaving a half-drawn bar behind."""
|
|
43
|
+
with self._lock:
|
|
44
|
+
if self.tty and self._drawn:
|
|
45
|
+
self.stream.write("\r" + " " * (self._columns() - 1) + "\r")
|
|
46
|
+
self.stream.write(text + "\n")
|
|
47
|
+
self.stream.flush()
|
|
48
|
+
if self.tty:
|
|
49
|
+
self._draw()
|
|
50
|
+
|
|
51
|
+
def close(self) -> None:
|
|
52
|
+
with self._lock:
|
|
53
|
+
if self.tty and self._drawn:
|
|
54
|
+
self.stream.write("\n")
|
|
55
|
+
self.stream.flush()
|
|
56
|
+
self._drawn = False
|
|
57
|
+
|
|
58
|
+
# -- internals
|
|
59
|
+
def _columns(self) -> int:
|
|
60
|
+
return shutil.get_terminal_size((80, 20)).columns
|
|
61
|
+
|
|
62
|
+
def _eta(self) -> str:
|
|
63
|
+
if self.done < 3:
|
|
64
|
+
return "--"
|
|
65
|
+
elapsed = time.monotonic() - self.start
|
|
66
|
+
return "~" + hms(elapsed / self.done * (self.total - self.done))
|
|
67
|
+
|
|
68
|
+
def _variants(self) -> list[str]:
|
|
69
|
+
pct = 100 * self.done // self.total
|
|
70
|
+
filled = 20 * self.done // self.total
|
|
71
|
+
bar = "[" + "█" * filled + "░" * (20 - filled) + "]"
|
|
72
|
+
elapsed, eta = hms(time.monotonic() - self.start), self._eta()
|
|
73
|
+
where = f"{self.system} (system {self.sys_index}/{self.sys_count})" if self.system else ""
|
|
74
|
+
return [
|
|
75
|
+
f"{bar} {pct:3d}% {self.done}/{self.total} calls | {where} {self.sys_done}/{self.sys_calls} | "
|
|
76
|
+
f"elapsed {elapsed} | ETA {eta}",
|
|
77
|
+
f"{bar} {pct:3d}% {self.done}/{self.total} calls | {where} | ETA {eta}",
|
|
78
|
+
f"{bar} {pct:3d}% {self.done}/{self.total} | ETA {eta}",
|
|
79
|
+
f"{pct}% ETA {eta}",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def _draw(self) -> None:
|
|
83
|
+
cols = self._columns()
|
|
84
|
+
variants = self._variants()
|
|
85
|
+
line = next((v for v in variants if len(v) < cols), variants[-1][: cols - 1])
|
|
86
|
+
self.stream.write("\r" + line.ljust(cols - 1))
|
|
87
|
+
self.stream.flush()
|
|
88
|
+
self._drawn, self._last_draw = True, time.monotonic()
|
|
89
|
+
|
|
90
|
+
def _update(self, force: bool = False) -> None:
|
|
91
|
+
if self.tty:
|
|
92
|
+
if force or time.monotonic() - self._last_draw >= 0.2:
|
|
93
|
+
self._draw()
|
|
94
|
+
return
|
|
95
|
+
pct = 100 * self.done // self.total
|
|
96
|
+
if pct >= self._next_mark: # plain log line every 10%
|
|
97
|
+
self._next_mark = (pct // 10 + 1) * 10
|
|
98
|
+
where = f" | {self.system} (system {self.sys_index}/{self.sys_count})" if self.system else ""
|
|
99
|
+
self.stream.write(f"progress: {pct}% ({self.done}/{self.total} calls){where} | "
|
|
100
|
+
f"elapsed {hms(time.monotonic() - self.start)} | ETA {self._eta()}\n")
|
|
101
|
+
self.stream.flush()
|
flakeval/scoring.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Turning a model's answer into a score: the built-in scorers and a few text helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
|
|
8
|
+
NUMBER = re.compile(r"-?\d[\d,]*(?:\.\d+)?")
|
|
9
|
+
ANSWER_LINE = re.compile(r"answer:[\s*$]*(-?[\d,]*\.?\d+)", re.IGNORECASE) # "Answer: 18", "Answer: **$1,200**"
|
|
10
|
+
BOXED = re.compile(r"\\boxed\{\s*\\?\$?\s*(-?[\d,]*\.?\d+)") # \boxed{13}
|
|
11
|
+
CHOICE = re.compile(r"\b([A-H])\b") # a standalone capital letter
|
|
12
|
+
|
|
13
|
+
SCORERS = ("exact", "contains", "number", "choice_letter", "answer_line", "final_number", "custom")
|
|
14
|
+
SCORER_HELP = """\
|
|
15
|
+
"exact" the answer equals the expected text (ignores case, spaces and trailing punctuation)
|
|
16
|
+
"contains" the expected text appears somewhere in the answer
|
|
17
|
+
"number" the LAST number in the answer equals the expected number
|
|
18
|
+
"choice_letter" multiple choice: the FIRST standalone capital letter A-H in the answer equals the expected letter
|
|
19
|
+
"answer_line" strict math: the number after the LAST "Answer:" in the reply equals the expected number
|
|
20
|
+
"final_number" lenient math: like answer_line, but if there is no "Answer:" line it also accepts \\boxed{...}
|
|
21
|
+
and finally the last number in the reply
|
|
22
|
+
"custom" your own function called custom_score"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fold_text(text) -> str:
|
|
26
|
+
"""Treat look-alike characters as the same (for example the subscript in H₂O becomes H2O)."""
|
|
27
|
+
return unicodedata.normalize("NFKC", str(text))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def question_hash(text: str) -> str:
|
|
31
|
+
"""Short fingerprint of a question, saved with each answer so --rescore can tell if the question was edited."""
|
|
32
|
+
return hashlib.sha1(str(text).encode("utf-8")).hexdigest()[:10]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_text(text) -> str:
|
|
36
|
+
return re.sub(r"\s+", " ", fold_text(text).strip().lower()).strip(" .!?\"'")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def to_number(text: str):
|
|
40
|
+
try:
|
|
41
|
+
return float(fold_text(text).replace(",", ""))
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _last_number(pattern: re.Pattern, text: str):
|
|
47
|
+
found = pattern.findall(text)
|
|
48
|
+
return to_number(found[-1]) if found else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _matches_number(got, expected) -> float:
|
|
52
|
+
want = to_number(str(expected).replace("$", ""))
|
|
53
|
+
return float(got is not None and want is not None and abs(got - want) < 1e-6)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def score_output(scorer: str, output: str, expected, row: dict, custom=None) -> float:
|
|
57
|
+
if scorer == "exact":
|
|
58
|
+
return float(normalize_text(output) == normalize_text(expected))
|
|
59
|
+
if scorer == "contains":
|
|
60
|
+
return float(normalize_text(expected) in normalize_text(output))
|
|
61
|
+
if scorer == "number":
|
|
62
|
+
found = NUMBER.findall(fold_text(output))
|
|
63
|
+
got, want = (to_number(found[-1]) if found else None), to_number(expected)
|
|
64
|
+
return float(got is not None and want is not None and abs(got - want) < 1e-6)
|
|
65
|
+
if scorer == "choice_letter":
|
|
66
|
+
match = CHOICE.search(fold_text(output).strip())
|
|
67
|
+
return float(bool(match) and match.group(1) == str(expected).strip().upper())
|
|
68
|
+
if scorer in ("answer_line", "final_number"):
|
|
69
|
+
text = fold_text(output)
|
|
70
|
+
got = _last_number(ANSWER_LINE, text)
|
|
71
|
+
if got is None and scorer == "final_number":
|
|
72
|
+
got = _last_number(BOXED, text)
|
|
73
|
+
if got is None:
|
|
74
|
+
got = _last_number(NUMBER, text)
|
|
75
|
+
return _matches_number(got, expected)
|
|
76
|
+
if scorer == "custom":
|
|
77
|
+
return float(custom(output, expected, row))
|
|
78
|
+
raise SystemExit(f"Unknown SCORER {scorer!r}. Choose one of:\n{SCORER_HELP}")
|