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 ADDED
@@ -0,0 +1,5 @@
1
+ from .io import load_jsonl, repeat, save_jsonl
2
+ from .stats import Comparison, Scores, Summary, compare, summarize
3
+
4
+ __all__ = ["Scores", "Summary", "Comparison", "summarize", "compare", "repeat", "load_jsonl", "save_jsonl"]
5
+ __version__ = "0.1.0"
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}")