abeval 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.
- abeval/__init__.py +34 -0
- abeval/__main__.py +191 -0
- abeval/_dist.py +109 -0
- abeval/ci.py +124 -0
- abeval/compare.py +131 -0
- abeval/data.py +54 -0
- abeval/power.py +127 -0
- abeval/reliability.py +85 -0
- abeval-0.1.0.dist-info/METADATA +201 -0
- abeval-0.1.0.dist-info/RECORD +13 -0
- abeval-0.1.0.dist-info/WHEEL +4 -0
- abeval-0.1.0.dist-info/entry_points.txt +2 -0
- abeval-0.1.0.dist-info/licenses/LICENSE +21 -0
abeval/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""abeval: A/B-test statistics for LLM evals.
|
|
2
|
+
|
|
3
|
+
Error bars for a single run, paired significance tests for comparing two
|
|
4
|
+
runs, sample-size planning before you spend on inference, and judge-noise
|
|
5
|
+
reliability. Standard library only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .ci import Interval, clustered_mean_ci, mean_ci, proportion_ci
|
|
9
|
+
from .compare import Comparison, mcnemar_exact, paired_compare
|
|
10
|
+
from .data import extract, pair, read_jsonl
|
|
11
|
+
from .power import mde, power_simulated, sample_size, sd_diff_from_rates
|
|
12
|
+
from .reliability import Reliability, judge_reliability
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Comparison",
|
|
18
|
+
"Interval",
|
|
19
|
+
"Reliability",
|
|
20
|
+
"__version__",
|
|
21
|
+
"clustered_mean_ci",
|
|
22
|
+
"extract",
|
|
23
|
+
"judge_reliability",
|
|
24
|
+
"mcnemar_exact",
|
|
25
|
+
"mde",
|
|
26
|
+
"mean_ci",
|
|
27
|
+
"pair",
|
|
28
|
+
"paired_compare",
|
|
29
|
+
"power_simulated",
|
|
30
|
+
"proportion_ci",
|
|
31
|
+
"read_jsonl",
|
|
32
|
+
"sample_size",
|
|
33
|
+
"sd_diff_from_rates",
|
|
34
|
+
]
|
abeval/__main__.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Command-line interface for abeval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .ci import clustered_mean_ci, mean_ci, proportion_ci
|
|
11
|
+
from .compare import paired_compare
|
|
12
|
+
from .data import extract, pair, read_jsonl
|
|
13
|
+
from .power import mde, sample_size, sd_diff_from_rates
|
|
14
|
+
from .reliability import judge_reliability
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _fmt_pct(x: float) -> str:
|
|
18
|
+
return f"{100.0 * x:.1f}%"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_binary(values: list[float]) -> bool:
|
|
22
|
+
return all(v in (0.0, 1.0) for v in values)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd_ci(args: argparse.Namespace) -> int:
|
|
26
|
+
records = read_jsonl(args.results)
|
|
27
|
+
values_by_id = extract(records, args.metric, args.id_key)
|
|
28
|
+
values = list(values_by_id.values())
|
|
29
|
+
if _is_binary(values) and not args.bootstrap:
|
|
30
|
+
interval = proportion_ci(int(sum(values)), len(values), args.level)
|
|
31
|
+
elif args.cluster_key:
|
|
32
|
+
clusters = [rec[args.cluster_key] for rec in records]
|
|
33
|
+
interval = clustered_mean_ci(values, clusters, args.level)
|
|
34
|
+
elif args.bootstrap:
|
|
35
|
+
interval = mean_ci(values, args.level, method="bootstrap", reps=args.reps, seed=args.seed)
|
|
36
|
+
else:
|
|
37
|
+
interval = mean_ci(values, args.level, method="t")
|
|
38
|
+
if args.json:
|
|
39
|
+
print(json.dumps(interval.as_dict(), indent=2))
|
|
40
|
+
return 0
|
|
41
|
+
pct = _is_binary(values)
|
|
42
|
+
fmt = _fmt_pct if pct else lambda v: f"{v:.4g}"
|
|
43
|
+
print(f"{args.metric}: {fmt(interval.estimate)} "
|
|
44
|
+
f"[{fmt(interval.lo)}, {fmt(interval.hi)}] "
|
|
45
|
+
f"(n={interval.n}, {int(interval.level * 100)}% CI, {interval.method})")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_compare(args: argparse.Namespace) -> int:
|
|
50
|
+
rec_a = read_jsonl(args.a)
|
|
51
|
+
rec_b = read_jsonl(args.b)
|
|
52
|
+
map_a = extract(rec_a, args.metric, args.id_key)
|
|
53
|
+
map_b = extract(rec_b, args.metric, args.id_key)
|
|
54
|
+
values_a, values_b, dropped = pair(map_a, map_b)
|
|
55
|
+
if not values_a:
|
|
56
|
+
print("error: no shared item ids between the two runs", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
if dropped:
|
|
59
|
+
print(f"note: {dropped} unpaired item(s) dropped", file=sys.stderr)
|
|
60
|
+
result = paired_compare(values_a, values_b, args.level, args.reps, args.seed)
|
|
61
|
+
if args.json:
|
|
62
|
+
print(json.dumps(result.as_dict(), indent=2))
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
pct = result.binary
|
|
66
|
+
fmt = _fmt_pct if pct else lambda v: f"{v:+.4g}"
|
|
67
|
+
plain = _fmt_pct if pct else lambda v: f"{v:.4g}"
|
|
68
|
+
sign = "+" if result.diff >= 0 else ""
|
|
69
|
+
print(f"A: {plain(result.mean_a)} B: {plain(result.mean_b)} (n={result.n} paired items)")
|
|
70
|
+
print(f"B - A: {sign}{fmt(result.diff).lstrip('+') if not pct else fmt(result.diff)} "
|
|
71
|
+
f"[{fmt(result.ci_lo)}, {fmt(result.ci_hi)}] ({int(result.level * 100)}% CI)")
|
|
72
|
+
print(f"p (sign-flip permutation): {result.p_permutation:.4f} p (paired t): {result.p_t:.4f}")
|
|
73
|
+
if result.mcnemar is not None:
|
|
74
|
+
mc = result.mcnemar
|
|
75
|
+
print(f"discordant items: B-only wins {mc['b_only']}, A-only wins {mc['a_only']} "
|
|
76
|
+
f"p (exact McNemar): {mc['p']:.4f}")
|
|
77
|
+
verdict = (
|
|
78
|
+
"significant at the chosen level"
|
|
79
|
+
if result.p_permutation < 1.0 - args.level
|
|
80
|
+
else "NOT significant — the difference is within noise at this sample size"
|
|
81
|
+
)
|
|
82
|
+
print(f"verdict: {verdict}")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_power(args: argparse.Namespace) -> int:
|
|
87
|
+
sd = sd_diff_from_rates(args.baseline, args.baseline + args.delta, args.corr)
|
|
88
|
+
n = sample_size(args.delta, sd, args.level, args.power)
|
|
89
|
+
out = {
|
|
90
|
+
"baseline": args.baseline,
|
|
91
|
+
"delta": args.delta,
|
|
92
|
+
"corr": args.corr,
|
|
93
|
+
"sd_diff": sd,
|
|
94
|
+
"level": args.level,
|
|
95
|
+
"power": args.power,
|
|
96
|
+
"n_items": n,
|
|
97
|
+
}
|
|
98
|
+
if args.json:
|
|
99
|
+
print(json.dumps(out, indent=2))
|
|
100
|
+
return 0
|
|
101
|
+
print(f"to detect {_fmt_pct(abs(args.delta))} difference from a "
|
|
102
|
+
f"{_fmt_pct(args.baseline)} baseline (corr={args.corr}):")
|
|
103
|
+
print(f" n = {n} paired items "
|
|
104
|
+
f"({int(args.power * 100)}% power, {int(args.level * 100)}% confidence)")
|
|
105
|
+
for n_have in (100, 200, 500, 1000):
|
|
106
|
+
d = mde(n_have, sd, args.level, args.power)
|
|
107
|
+
print(f" with {n_have:>4} items you can detect >= {_fmt_pct(d)}")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_reliability(args: argparse.Namespace) -> int:
|
|
112
|
+
records = read_jsonl(args.results)
|
|
113
|
+
groups: dict = {}
|
|
114
|
+
for rec in records:
|
|
115
|
+
if args.id_key not in rec or args.metric not in rec:
|
|
116
|
+
raise ValueError(f"record missing {args.id_key!r} or {args.metric!r}: {rec}")
|
|
117
|
+
groups.setdefault(rec[args.id_key], []).append(rec[args.metric])
|
|
118
|
+
rel = judge_reliability(groups)
|
|
119
|
+
if args.json:
|
|
120
|
+
print(json.dumps(rel.as_dict(), indent=2))
|
|
121
|
+
return 0
|
|
122
|
+
print(f"{rel.n_items} items, {rel.n_judgments} judgments "
|
|
123
|
+
f"({rel.mean_reps:.1f} per item)")
|
|
124
|
+
print(f"ICC (signal share of variance): {rel.icc:.3f}")
|
|
125
|
+
print(f"between-item sd: {rel.sd_between:.4g} judge noise sd: {rel.sd_within:.4g}")
|
|
126
|
+
print(f"exact agreement across repeats: {_fmt_pct(rel.exact_agreement)}")
|
|
127
|
+
reps = rel.repeats_for(0.1)
|
|
128
|
+
if reps is not None:
|
|
129
|
+
print(f"repeats per item to push judge noise under 10%: {reps}")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main(argv: list[str] | None = None) -> int:
|
|
134
|
+
parser = argparse.ArgumentParser(
|
|
135
|
+
prog="abeval",
|
|
136
|
+
description="A/B-test statistics for LLM evals.",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument("--version", action="version", version=f"abeval {__version__}")
|
|
139
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
140
|
+
|
|
141
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
142
|
+
common.add_argument("--metric", default="score", help="metric field name (default: score)")
|
|
143
|
+
common.add_argument("--id-key", default="id", help="item id field name (default: id)")
|
|
144
|
+
common.add_argument("--level", type=float, default=0.95, help="confidence level (default: 0.95)")
|
|
145
|
+
common.add_argument("--seed", type=int, default=0, help="RNG seed (default: 0)")
|
|
146
|
+
common.add_argument("--reps", type=int, default=10_000,
|
|
147
|
+
help="bootstrap/permutation resamples (default: 10000)")
|
|
148
|
+
common.add_argument("--json", action="store_true", help="emit JSON instead of text")
|
|
149
|
+
|
|
150
|
+
p_ci = sub.add_parser("ci", parents=[common],
|
|
151
|
+
help="confidence interval for one run's metric")
|
|
152
|
+
p_ci.add_argument("results", help="JSONL file with per-item results")
|
|
153
|
+
p_ci.add_argument("--bootstrap", action="store_true",
|
|
154
|
+
help="percentile bootstrap instead of t/Wilson")
|
|
155
|
+
p_ci.add_argument("--cluster-key", default=None,
|
|
156
|
+
help="field defining dependence clusters (e.g. source document)")
|
|
157
|
+
p_ci.set_defaults(fn=cmd_ci)
|
|
158
|
+
|
|
159
|
+
p_cmp = sub.add_parser("compare", parents=[common],
|
|
160
|
+
help="paired comparison of two runs on shared items")
|
|
161
|
+
p_cmp.add_argument("a", help="baseline run JSONL")
|
|
162
|
+
p_cmp.add_argument("b", help="candidate run JSONL")
|
|
163
|
+
p_cmp.set_defaults(fn=cmd_compare)
|
|
164
|
+
|
|
165
|
+
p_pow = sub.add_parser("power", parents=[common],
|
|
166
|
+
help="how many items you need before you run")
|
|
167
|
+
p_pow.add_argument("--baseline", type=float, required=True,
|
|
168
|
+
help="expected baseline accuracy, e.g. 0.75")
|
|
169
|
+
p_pow.add_argument("--delta", type=float, required=True,
|
|
170
|
+
help="difference you care about detecting, e.g. 0.03")
|
|
171
|
+
p_pow.add_argument("--corr", type=float, default=0.5,
|
|
172
|
+
help="item-level correlation between runs (default: 0.5)")
|
|
173
|
+
p_pow.add_argument("--power", type=float, default=0.8,
|
|
174
|
+
help="target power (default: 0.8)")
|
|
175
|
+
p_pow.set_defaults(fn=cmd_power)
|
|
176
|
+
|
|
177
|
+
p_rel = sub.add_parser("reliability", parents=[common],
|
|
178
|
+
help="judge-noise decomposition from repeated judgments")
|
|
179
|
+
p_rel.add_argument("results", help="JSONL with repeated judgments per item id")
|
|
180
|
+
p_rel.set_defaults(fn=cmd_reliability)
|
|
181
|
+
|
|
182
|
+
args = parser.parse_args(argv)
|
|
183
|
+
try:
|
|
184
|
+
return args.fn(args)
|
|
185
|
+
except (ValueError, OSError) as exc:
|
|
186
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
187
|
+
return 2
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
raise SystemExit(main())
|
abeval/_dist.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Distribution helpers used by the estimators.
|
|
2
|
+
|
|
3
|
+
Everything here is standard-library only. The Student-t CDF goes through the
|
|
4
|
+
regularized incomplete beta function (continued-fraction expansion), and the
|
|
5
|
+
quantile inverts it by bisection — slower than a closed form but robust for
|
|
6
|
+
every df, which is what a stats tool should optimize for.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from statistics import NormalDist
|
|
13
|
+
|
|
14
|
+
_NORMAL = NormalDist()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def z_quantile(p: float) -> float:
|
|
18
|
+
"""Standard normal quantile."""
|
|
19
|
+
if not 0.0 < p < 1.0:
|
|
20
|
+
raise ValueError("p must be in (0, 1)")
|
|
21
|
+
return _NORMAL.inv_cdf(p)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normal_cdf(x: float) -> float:
|
|
25
|
+
return _NORMAL.cdf(x)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _betacf(a: float, b: float, x: float) -> float:
|
|
29
|
+
"""Continued fraction for the incomplete beta function (Lentz's method)."""
|
|
30
|
+
max_iter = 300
|
|
31
|
+
eps = 3e-12
|
|
32
|
+
fpmin = 1e-300
|
|
33
|
+
qab = a + b
|
|
34
|
+
qap = a + 1.0
|
|
35
|
+
qam = a - 1.0
|
|
36
|
+
c = 1.0
|
|
37
|
+
d = 1.0 - qab * x / qap
|
|
38
|
+
if abs(d) < fpmin:
|
|
39
|
+
d = fpmin
|
|
40
|
+
d = 1.0 / d
|
|
41
|
+
h = d
|
|
42
|
+
for m in range(1, max_iter + 1):
|
|
43
|
+
m2 = 2 * m
|
|
44
|
+
aa = m * (b - m) * x / ((qam + m2) * (a + m2))
|
|
45
|
+
d = 1.0 + aa * d
|
|
46
|
+
if abs(d) < fpmin:
|
|
47
|
+
d = fpmin
|
|
48
|
+
c = 1.0 + aa / c
|
|
49
|
+
if abs(c) < fpmin:
|
|
50
|
+
c = fpmin
|
|
51
|
+
d = 1.0 / d
|
|
52
|
+
h *= d * c
|
|
53
|
+
aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
|
|
54
|
+
d = 1.0 + aa * d
|
|
55
|
+
if abs(d) < fpmin:
|
|
56
|
+
d = fpmin
|
|
57
|
+
c = 1.0 + aa / c
|
|
58
|
+
if abs(c) < fpmin:
|
|
59
|
+
c = fpmin
|
|
60
|
+
d = 1.0 / d
|
|
61
|
+
delta = d * c
|
|
62
|
+
h *= delta
|
|
63
|
+
if abs(delta - 1.0) < eps:
|
|
64
|
+
break
|
|
65
|
+
return h
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def reg_inc_beta(a: float, b: float, x: float) -> float:
|
|
69
|
+
"""Regularized incomplete beta I_x(a, b)."""
|
|
70
|
+
if x <= 0.0:
|
|
71
|
+
return 0.0
|
|
72
|
+
if x >= 1.0:
|
|
73
|
+
return 1.0
|
|
74
|
+
ln_front = (
|
|
75
|
+
math.lgamma(a + b)
|
|
76
|
+
- math.lgamma(a)
|
|
77
|
+
- math.lgamma(b)
|
|
78
|
+
+ a * math.log(x)
|
|
79
|
+
+ b * math.log1p(-x)
|
|
80
|
+
)
|
|
81
|
+
front = math.exp(ln_front)
|
|
82
|
+
if x < (a + 1.0) / (a + b + 2.0):
|
|
83
|
+
return front * _betacf(a, b, x) / a
|
|
84
|
+
return 1.0 - front * _betacf(b, a, 1.0 - x) / b
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def t_cdf(t: float, df: float) -> float:
|
|
88
|
+
"""CDF of Student's t with `df` degrees of freedom."""
|
|
89
|
+
if df <= 0:
|
|
90
|
+
raise ValueError("df must be positive")
|
|
91
|
+
x = df / (df + t * t)
|
|
92
|
+
p_tail = 0.5 * reg_inc_beta(df / 2.0, 0.5, x)
|
|
93
|
+
return 1.0 - p_tail if t > 0 else p_tail
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def t_quantile(p: float, df: float) -> float:
|
|
97
|
+
"""Quantile of Student's t, by bisection on the CDF."""
|
|
98
|
+
if not 0.0 < p < 1.0:
|
|
99
|
+
raise ValueError("p must be in (0, 1)")
|
|
100
|
+
if df <= 0:
|
|
101
|
+
raise ValueError("df must be positive")
|
|
102
|
+
lo, hi = -1e8, 1e8
|
|
103
|
+
for _ in range(200):
|
|
104
|
+
mid = (lo + hi) / 2.0
|
|
105
|
+
if t_cdf(mid, df) < p:
|
|
106
|
+
lo = mid
|
|
107
|
+
else:
|
|
108
|
+
hi = mid
|
|
109
|
+
return (lo + hi) / 2.0
|
abeval/ci.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Confidence intervals for a single eval run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from ._dist import t_quantile, z_quantile
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Interval:
|
|
14
|
+
estimate: float
|
|
15
|
+
lo: float
|
|
16
|
+
hi: float
|
|
17
|
+
n: int
|
|
18
|
+
method: str
|
|
19
|
+
level: float
|
|
20
|
+
se: float | None = None
|
|
21
|
+
|
|
22
|
+
def as_dict(self) -> dict:
|
|
23
|
+
return {
|
|
24
|
+
"estimate": self.estimate,
|
|
25
|
+
"lo": self.lo,
|
|
26
|
+
"hi": self.hi,
|
|
27
|
+
"n": self.n,
|
|
28
|
+
"method": self.method,
|
|
29
|
+
"level": self.level,
|
|
30
|
+
"se": self.se,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def proportion_ci(successes: int, n: int, level: float = 0.95) -> Interval:
|
|
35
|
+
"""Wilson score interval for a binomial proportion."""
|
|
36
|
+
if n <= 0:
|
|
37
|
+
raise ValueError("n must be positive")
|
|
38
|
+
if not 0 <= successes <= n:
|
|
39
|
+
raise ValueError("successes must be in [0, n]")
|
|
40
|
+
z = z_quantile(1.0 - (1.0 - level) / 2.0)
|
|
41
|
+
p_hat = successes / n
|
|
42
|
+
denom = 1.0 + z * z / n
|
|
43
|
+
center = (p_hat + z * z / (2.0 * n)) / denom
|
|
44
|
+
half = (z / denom) * math.sqrt(p_hat * (1.0 - p_hat) / n + z * z / (4.0 * n * n))
|
|
45
|
+
return Interval(
|
|
46
|
+
estimate=p_hat,
|
|
47
|
+
lo=max(0.0, center - half),
|
|
48
|
+
hi=min(1.0, center + half),
|
|
49
|
+
n=n,
|
|
50
|
+
method="wilson",
|
|
51
|
+
level=level,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _mean(values: list[float]) -> float:
|
|
56
|
+
return sum(values) / len(values)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _sample_sd(values: list[float]) -> float:
|
|
60
|
+
n = len(values)
|
|
61
|
+
m = _mean(values)
|
|
62
|
+
return math.sqrt(sum((v - m) ** 2 for v in values) / (n - 1))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def mean_ci(
|
|
66
|
+
values: list[float],
|
|
67
|
+
level: float = 0.95,
|
|
68
|
+
method: str = "t",
|
|
69
|
+
reps: int = 10_000,
|
|
70
|
+
seed: int = 0,
|
|
71
|
+
) -> Interval:
|
|
72
|
+
"""CI for the mean of per-item values.
|
|
73
|
+
|
|
74
|
+
method="t" uses the t distribution; method="bootstrap" uses the percentile
|
|
75
|
+
bootstrap with a seeded RNG so results are reproducible.
|
|
76
|
+
"""
|
|
77
|
+
n = len(values)
|
|
78
|
+
if n < 2:
|
|
79
|
+
raise ValueError("need at least 2 values")
|
|
80
|
+
m = _mean(values)
|
|
81
|
+
if method == "t":
|
|
82
|
+
se = _sample_sd(values) / math.sqrt(n)
|
|
83
|
+
t = t_quantile(1.0 - (1.0 - level) / 2.0, n - 1)
|
|
84
|
+
return Interval(m, m - t * se, m + t * se, n, "t", level, se=se)
|
|
85
|
+
if method == "bootstrap":
|
|
86
|
+
rng = random.Random(seed)
|
|
87
|
+
stats = sorted(
|
|
88
|
+
_mean([values[rng.randrange(n)] for _ in range(n)]) for _ in range(reps)
|
|
89
|
+
)
|
|
90
|
+
alpha = (1.0 - level) / 2.0
|
|
91
|
+
lo = stats[int(alpha * reps)]
|
|
92
|
+
hi = stats[min(reps - 1, int((1.0 - alpha) * reps))]
|
|
93
|
+
return Interval(m, lo, hi, n, "bootstrap", level)
|
|
94
|
+
raise ValueError(f"unknown method {method!r}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def clustered_mean_ci(
|
|
98
|
+
values: list[float],
|
|
99
|
+
clusters: list,
|
|
100
|
+
level: float = 0.95,
|
|
101
|
+
) -> Interval:
|
|
102
|
+
"""CI for a mean when items are not independent within clusters.
|
|
103
|
+
|
|
104
|
+
Uses the cluster-robust variance of the mean (sum of squared within-cluster
|
|
105
|
+
residual sums over n^2) with a t reference on G-1 degrees of freedom, where
|
|
106
|
+
G is the number of clusters. With few clusters (< ~20) treat the interval
|
|
107
|
+
as approximate.
|
|
108
|
+
"""
|
|
109
|
+
if len(values) != len(clusters):
|
|
110
|
+
raise ValueError("values and clusters must have the same length")
|
|
111
|
+
n = len(values)
|
|
112
|
+
if n < 2:
|
|
113
|
+
raise ValueError("need at least 2 values")
|
|
114
|
+
m = _mean(values)
|
|
115
|
+
sums: dict = {}
|
|
116
|
+
for v, c in zip(values, clusters):
|
|
117
|
+
sums[c] = sums.get(c, 0.0) + (v - m)
|
|
118
|
+
n_clusters = len(sums)
|
|
119
|
+
if n_clusters < 2:
|
|
120
|
+
raise ValueError("need at least 2 clusters")
|
|
121
|
+
var = sum(s * s for s in sums.values()) / (n * n)
|
|
122
|
+
se = math.sqrt(var)
|
|
123
|
+
t = t_quantile(1.0 - (1.0 - level) / 2.0, n_clusters - 1)
|
|
124
|
+
return Interval(m, m - t * se, m + t * se, n, f"clustered-t ({n_clusters} clusters)", level, se=se)
|
abeval/compare.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Paired comparison of two eval runs on the same items."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from ._dist import t_cdf
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Comparison:
|
|
14
|
+
n: int
|
|
15
|
+
mean_a: float
|
|
16
|
+
mean_b: float
|
|
17
|
+
diff: float # mean(b - a): positive means B is better
|
|
18
|
+
ci_lo: float
|
|
19
|
+
ci_hi: float
|
|
20
|
+
level: float
|
|
21
|
+
p_permutation: float
|
|
22
|
+
p_t: float
|
|
23
|
+
reps: int
|
|
24
|
+
seed: int
|
|
25
|
+
binary: bool
|
|
26
|
+
mcnemar: dict | None = field(default=None)
|
|
27
|
+
|
|
28
|
+
def as_dict(self) -> dict:
|
|
29
|
+
out = {
|
|
30
|
+
"n": self.n,
|
|
31
|
+
"mean_a": self.mean_a,
|
|
32
|
+
"mean_b": self.mean_b,
|
|
33
|
+
"diff": self.diff,
|
|
34
|
+
"ci_lo": self.ci_lo,
|
|
35
|
+
"ci_hi": self.ci_hi,
|
|
36
|
+
"level": self.level,
|
|
37
|
+
"p_permutation": self.p_permutation,
|
|
38
|
+
"p_t": self.p_t,
|
|
39
|
+
"reps": self.reps,
|
|
40
|
+
"seed": self.seed,
|
|
41
|
+
"binary": self.binary,
|
|
42
|
+
}
|
|
43
|
+
if self.mcnemar is not None:
|
|
44
|
+
out["mcnemar"] = self.mcnemar
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def mcnemar_exact(a: list[float], b: list[float]) -> dict:
|
|
49
|
+
"""Exact McNemar test for paired binary outcomes.
|
|
50
|
+
|
|
51
|
+
b_only = items B got right and A got wrong; a_only = the reverse.
|
|
52
|
+
The p-value is the exact two-sided binomial sign test on the discordant
|
|
53
|
+
pairs (p = 0.5 under the null), clamped to 1.
|
|
54
|
+
"""
|
|
55
|
+
a_only = sum(1 for x, y in zip(a, b) if x == 1 and y == 0)
|
|
56
|
+
b_only = sum(1 for x, y in zip(a, b) if x == 0 and y == 1)
|
|
57
|
+
n_disc = a_only + b_only
|
|
58
|
+
if n_disc == 0:
|
|
59
|
+
return {"a_only": 0, "b_only": 0, "p": 1.0}
|
|
60
|
+
k = min(a_only, b_only)
|
|
61
|
+
tail = sum(math.comb(n_disc, i) for i in range(k + 1)) / 2.0**n_disc
|
|
62
|
+
return {"a_only": a_only, "b_only": b_only, "p": min(1.0, 2.0 * tail)}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def paired_compare(
|
|
66
|
+
a: list[float],
|
|
67
|
+
b: list[float],
|
|
68
|
+
level: float = 0.95,
|
|
69
|
+
reps: int = 10_000,
|
|
70
|
+
seed: int = 0,
|
|
71
|
+
) -> Comparison:
|
|
72
|
+
"""Compare two runs scored on the same items.
|
|
73
|
+
|
|
74
|
+
Reports mean(b - a) with a paired percentile-bootstrap CI, a sign-flip
|
|
75
|
+
permutation p-value (exact under item exchangeability), a paired-t
|
|
76
|
+
p-value, and — when both runs are 0/1 — an exact McNemar test.
|
|
77
|
+
"""
|
|
78
|
+
if len(a) != len(b):
|
|
79
|
+
raise ValueError("runs must have the same length")
|
|
80
|
+
n = len(a)
|
|
81
|
+
if n < 2:
|
|
82
|
+
raise ValueError("need at least 2 paired items")
|
|
83
|
+
diffs = [y - x for x, y in zip(a, b)]
|
|
84
|
+
mean_diff = sum(diffs) / n
|
|
85
|
+
|
|
86
|
+
rng = random.Random(seed)
|
|
87
|
+
|
|
88
|
+
# Percentile bootstrap on the per-item differences.
|
|
89
|
+
boot = sorted(
|
|
90
|
+
sum(diffs[rng.randrange(n)] for _ in range(n)) / n for _ in range(reps)
|
|
91
|
+
)
|
|
92
|
+
alpha = (1.0 - level) / 2.0
|
|
93
|
+
ci_lo = boot[int(alpha * reps)]
|
|
94
|
+
ci_hi = boot[min(reps - 1, int((1.0 - alpha) * reps))]
|
|
95
|
+
|
|
96
|
+
# Sign-flip permutation test: under H0 each difference is symmetric
|
|
97
|
+
# around 0, so flipping signs at random regenerates the null.
|
|
98
|
+
obs = abs(mean_diff)
|
|
99
|
+
hits = 0
|
|
100
|
+
for _ in range(reps):
|
|
101
|
+
total = 0.0
|
|
102
|
+
for d in diffs:
|
|
103
|
+
total += d if rng.random() < 0.5 else -d
|
|
104
|
+
if abs(total / n) >= obs - 1e-15:
|
|
105
|
+
hits += 1
|
|
106
|
+
p_perm = (1 + hits) / (reps + 1)
|
|
107
|
+
|
|
108
|
+
# Paired t-test.
|
|
109
|
+
sd = math.sqrt(sum((d - mean_diff) ** 2 for d in diffs) / (n - 1))
|
|
110
|
+
if sd == 0.0:
|
|
111
|
+
p_t = 1.0 if mean_diff == 0.0 else 0.0
|
|
112
|
+
else:
|
|
113
|
+
t_stat = mean_diff / (sd / math.sqrt(n))
|
|
114
|
+
p_t = 2.0 * (1.0 - t_cdf(abs(t_stat), n - 1))
|
|
115
|
+
|
|
116
|
+
binary = all(v in (0.0, 1.0) for v in a) and all(v in (0.0, 1.0) for v in b)
|
|
117
|
+
return Comparison(
|
|
118
|
+
n=n,
|
|
119
|
+
mean_a=sum(a) / n,
|
|
120
|
+
mean_b=sum(b) / n,
|
|
121
|
+
diff=mean_diff,
|
|
122
|
+
ci_lo=ci_lo,
|
|
123
|
+
ci_hi=ci_hi,
|
|
124
|
+
level=level,
|
|
125
|
+
p_permutation=p_perm,
|
|
126
|
+
p_t=p_t,
|
|
127
|
+
reps=reps,
|
|
128
|
+
seed=seed,
|
|
129
|
+
binary=binary,
|
|
130
|
+
mcnemar=mcnemar_exact(a, b) if binary else None,
|
|
131
|
+
)
|
abeval/data.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Reading eval results and pairing two runs on a shared item id."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read_jsonl(path: str | Path) -> list[dict]:
|
|
10
|
+
"""Read a JSONL file into a list of dicts. Blank lines are skipped."""
|
|
11
|
+
records = []
|
|
12
|
+
with open(path, encoding="utf-8") as fh:
|
|
13
|
+
for line_no, line in enumerate(fh, 1):
|
|
14
|
+
line = line.strip()
|
|
15
|
+
if not line:
|
|
16
|
+
continue
|
|
17
|
+
try:
|
|
18
|
+
records.append(json.loads(line))
|
|
19
|
+
except json.JSONDecodeError as exc:
|
|
20
|
+
raise ValueError(f"{path}:{line_no}: invalid JSON: {exc}") from exc
|
|
21
|
+
return records
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract(records: list[dict], metric: str, id_key: str = "id") -> dict:
|
|
25
|
+
"""Map item id -> metric value. Duplicate ids and missing fields are errors."""
|
|
26
|
+
out: dict = {}
|
|
27
|
+
for rec in records:
|
|
28
|
+
if id_key not in rec:
|
|
29
|
+
raise ValueError(f"record missing id key {id_key!r}: {rec}")
|
|
30
|
+
if metric not in rec:
|
|
31
|
+
raise ValueError(f"record {rec[id_key]!r} missing metric {metric!r}")
|
|
32
|
+
item_id = rec[id_key]
|
|
33
|
+
if item_id in out:
|
|
34
|
+
raise ValueError(f"duplicate id {item_id!r}")
|
|
35
|
+
value = rec[metric]
|
|
36
|
+
if isinstance(value, bool):
|
|
37
|
+
value = int(value)
|
|
38
|
+
if not isinstance(value, (int, float)):
|
|
39
|
+
raise ValueError(f"metric {metric!r} for id {item_id!r} is not numeric: {value!r}")
|
|
40
|
+
out[item_id] = float(value)
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def pair(
|
|
45
|
+
a: dict, b: dict
|
|
46
|
+
) -> tuple[list[float], list[float], int]:
|
|
47
|
+
"""Inner-join two id->value maps.
|
|
48
|
+
|
|
49
|
+
Returns (a_values, b_values, n_dropped) where n_dropped counts ids present
|
|
50
|
+
in only one of the two runs. Order follows a's insertion order.
|
|
51
|
+
"""
|
|
52
|
+
shared = [item_id for item_id in a if item_id in b]
|
|
53
|
+
dropped = (len(a) - len(shared)) + (len(b) - len(shared))
|
|
54
|
+
return [a[i] for i in shared], [b[i] for i in shared], dropped
|
abeval/power.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Sample-size planning and minimum detectable effects for paired evals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
|
|
8
|
+
from ._dist import z_quantile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sd_diff_from_rates(p_a: float, p_b: float, corr: float) -> float:
|
|
12
|
+
"""SD of the per-item difference for paired binary outcomes.
|
|
13
|
+
|
|
14
|
+
corr is the item-level correlation between the two runs' outcomes
|
|
15
|
+
(0 = independent, higher = the models succeed and fail on the same items,
|
|
16
|
+
which is the typical case and what makes paired designs cheap).
|
|
17
|
+
"""
|
|
18
|
+
for p in (p_a, p_b):
|
|
19
|
+
if not 0.0 < p < 1.0:
|
|
20
|
+
raise ValueError("rates must be in (0, 1)")
|
|
21
|
+
if not -1.0 < corr < 1.0:
|
|
22
|
+
raise ValueError("corr must be in (-1, 1)")
|
|
23
|
+
var = (
|
|
24
|
+
p_a * (1.0 - p_a)
|
|
25
|
+
+ p_b * (1.0 - p_b)
|
|
26
|
+
- 2.0 * corr * math.sqrt(p_a * (1.0 - p_a) * p_b * (1.0 - p_b))
|
|
27
|
+
)
|
|
28
|
+
if var <= 0.0:
|
|
29
|
+
raise ValueError("implied variance is not positive; check the inputs")
|
|
30
|
+
return math.sqrt(var)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sample_size(
|
|
34
|
+
delta: float,
|
|
35
|
+
sd_diff: float,
|
|
36
|
+
level: float = 0.95,
|
|
37
|
+
power: float = 0.8,
|
|
38
|
+
) -> int:
|
|
39
|
+
"""Paired items needed to detect a true difference `delta`.
|
|
40
|
+
|
|
41
|
+
Normal-approximation two-sided test on the mean of per-item differences.
|
|
42
|
+
"""
|
|
43
|
+
if delta == 0.0:
|
|
44
|
+
raise ValueError("delta must be nonzero")
|
|
45
|
+
if sd_diff <= 0.0:
|
|
46
|
+
raise ValueError("sd_diff must be positive")
|
|
47
|
+
z_alpha = z_quantile(1.0 - (1.0 - level) / 2.0)
|
|
48
|
+
z_power = z_quantile(power)
|
|
49
|
+
n = ((z_alpha + z_power) * sd_diff / abs(delta)) ** 2
|
|
50
|
+
return max(2, math.ceil(n))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def mde(
|
|
54
|
+
n: int,
|
|
55
|
+
sd_diff: float,
|
|
56
|
+
level: float = 0.95,
|
|
57
|
+
power: float = 0.8,
|
|
58
|
+
) -> float:
|
|
59
|
+
"""Minimum detectable difference at a given number of paired items."""
|
|
60
|
+
if n < 2:
|
|
61
|
+
raise ValueError("n must be at least 2")
|
|
62
|
+
if sd_diff <= 0.0:
|
|
63
|
+
raise ValueError("sd_diff must be positive")
|
|
64
|
+
z_alpha = z_quantile(1.0 - (1.0 - level) / 2.0)
|
|
65
|
+
z_power = z_quantile(power)
|
|
66
|
+
return (z_alpha + z_power) * sd_diff / math.sqrt(n)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def paired_binary_cells(p_a: float, p_b: float, corr: float) -> dict:
|
|
70
|
+
"""Joint distribution of paired 0/1 outcomes with the given margins/corr."""
|
|
71
|
+
sd_a = math.sqrt(p_a * (1.0 - p_a))
|
|
72
|
+
sd_b = math.sqrt(p_b * (1.0 - p_b))
|
|
73
|
+
p_both = p_a * p_b + corr * sd_a * sd_b
|
|
74
|
+
cells = {
|
|
75
|
+
"both": p_both,
|
|
76
|
+
"a_only": p_a - p_both,
|
|
77
|
+
"b_only": p_b - p_both,
|
|
78
|
+
"neither": 1.0 - p_a - p_b + p_both,
|
|
79
|
+
}
|
|
80
|
+
if any(v < -1e-12 for v in cells.values()):
|
|
81
|
+
raise ValueError("that correlation is not achievable with these rates")
|
|
82
|
+
return {k: max(0.0, v) for k, v in cells.items()}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def power_simulated(
|
|
86
|
+
n: int,
|
|
87
|
+
p_a: float,
|
|
88
|
+
p_b: float,
|
|
89
|
+
corr: float,
|
|
90
|
+
level: float = 0.95,
|
|
91
|
+
reps: int = 2_000,
|
|
92
|
+
seed: int = 0,
|
|
93
|
+
) -> float:
|
|
94
|
+
"""Monte Carlo power of the paired z-test for binary outcomes.
|
|
95
|
+
|
|
96
|
+
A check on the normal-approximation formulas: simulates `reps` evals of n
|
|
97
|
+
paired items with the given accuracy margins and item-level correlation,
|
|
98
|
+
and reports the fraction where the test rejects at the given level.
|
|
99
|
+
"""
|
|
100
|
+
cells = paired_binary_cells(p_a, p_b, corr)
|
|
101
|
+
thresholds = []
|
|
102
|
+
acc = 0.0
|
|
103
|
+
for key in ("both", "a_only", "b_only", "neither"):
|
|
104
|
+
acc += cells[key]
|
|
105
|
+
thresholds.append((acc, key))
|
|
106
|
+
diff_of = {"both": 0.0, "neither": 0.0, "a_only": -1.0, "b_only": 1.0}
|
|
107
|
+
z_alpha = z_quantile(1.0 - (1.0 - level) / 2.0)
|
|
108
|
+
rng = random.Random(seed)
|
|
109
|
+
rejections = 0
|
|
110
|
+
for _ in range(reps):
|
|
111
|
+
diffs = []
|
|
112
|
+
for _ in range(n):
|
|
113
|
+
u = rng.random()
|
|
114
|
+
for cutoff, key in thresholds:
|
|
115
|
+
if u <= cutoff:
|
|
116
|
+
diffs.append(diff_of[key])
|
|
117
|
+
break
|
|
118
|
+
else:
|
|
119
|
+
diffs.append(0.0)
|
|
120
|
+
m = sum(diffs) / n
|
|
121
|
+
var = sum((d - m) ** 2 for d in diffs) / (n - 1)
|
|
122
|
+
if var == 0.0:
|
|
123
|
+
continue
|
|
124
|
+
z_stat = m / math.sqrt(var / n)
|
|
125
|
+
if abs(z_stat) > z_alpha:
|
|
126
|
+
rejections += 1
|
|
127
|
+
return rejections / reps
|
abeval/reliability.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""How noisy is the judge? Variance decomposition over repeated judgments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Reliability:
|
|
11
|
+
n_items: int
|
|
12
|
+
n_judgments: int
|
|
13
|
+
mean_reps: float
|
|
14
|
+
icc: float
|
|
15
|
+
sd_between: float # spread of true item scores
|
|
16
|
+
sd_within: float # judge noise on a single judgment
|
|
17
|
+
exact_agreement: float # fraction of items where every repeat matched
|
|
18
|
+
|
|
19
|
+
def repeats_for(self, noise_share: float = 0.1) -> int | None:
|
|
20
|
+
"""Repeats per item so judge noise is at most `noise_share` of the
|
|
21
|
+
variance of an item's averaged score. None when the between-item
|
|
22
|
+
variance is zero (no signal to protect)."""
|
|
23
|
+
if not 0.0 < noise_share < 1.0:
|
|
24
|
+
raise ValueError("noise_share must be in (0, 1)")
|
|
25
|
+
var_b = self.sd_between**2
|
|
26
|
+
var_w = self.sd_within**2
|
|
27
|
+
if var_b <= 0.0:
|
|
28
|
+
return None
|
|
29
|
+
if var_w == 0.0:
|
|
30
|
+
return 1
|
|
31
|
+
return max(1, math.ceil(var_w * (1.0 - noise_share) / (noise_share * var_b)))
|
|
32
|
+
|
|
33
|
+
def as_dict(self) -> dict:
|
|
34
|
+
return {
|
|
35
|
+
"n_items": self.n_items,
|
|
36
|
+
"n_judgments": self.n_judgments,
|
|
37
|
+
"mean_reps": self.mean_reps,
|
|
38
|
+
"icc": self.icc,
|
|
39
|
+
"sd_between": self.sd_between,
|
|
40
|
+
"sd_within": self.sd_within,
|
|
41
|
+
"exact_agreement": self.exact_agreement,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def judge_reliability(groups: dict) -> Reliability:
|
|
46
|
+
"""One-way random-effects decomposition of repeated judge scores.
|
|
47
|
+
|
|
48
|
+
`groups` maps item id -> list of scores from repeated judgments of the
|
|
49
|
+
same item. Unbalanced designs are fine (k0 correction). Returns the
|
|
50
|
+
intraclass correlation (share of variance that is real item signal rather
|
|
51
|
+
than judge noise) plus both variance components.
|
|
52
|
+
"""
|
|
53
|
+
groups = {k: [float(v) for v in vals] for k, vals in groups.items() if len(vals) >= 1}
|
|
54
|
+
if len(groups) < 2:
|
|
55
|
+
raise ValueError("need at least 2 items")
|
|
56
|
+
if all(len(v) < 2 for v in groups.values()):
|
|
57
|
+
raise ValueError("need repeated judgments (at least one item with 2+ scores)")
|
|
58
|
+
|
|
59
|
+
n_total = sum(len(v) for v in groups.values())
|
|
60
|
+
n_groups = len(groups)
|
|
61
|
+
grand = sum(sum(v) for v in groups.values()) / n_total
|
|
62
|
+
|
|
63
|
+
ss_between = sum(len(v) * (sum(v) / len(v) - grand) ** 2 for v in groups.values())
|
|
64
|
+
ss_within = sum(
|
|
65
|
+
sum((x - sum(v) / len(v)) ** 2 for x in v) for v in groups.values()
|
|
66
|
+
)
|
|
67
|
+
ms_between = ss_between / (n_groups - 1)
|
|
68
|
+
ms_within = ss_within / (n_total - n_groups) if n_total > n_groups else 0.0
|
|
69
|
+
|
|
70
|
+
k0 = (n_total - sum(len(v) ** 2 for v in groups.values()) / n_total) / (n_groups - 1)
|
|
71
|
+
var_within = ms_within
|
|
72
|
+
var_between = max(0.0, (ms_between - ms_within) / k0) if k0 > 0 else 0.0
|
|
73
|
+
total = var_between + var_within
|
|
74
|
+
icc = var_between / total if total > 0 else 0.0
|
|
75
|
+
|
|
76
|
+
exact = sum(1 for v in groups.values() if len(set(v)) == 1) / n_groups
|
|
77
|
+
return Reliability(
|
|
78
|
+
n_items=n_groups,
|
|
79
|
+
n_judgments=n_total,
|
|
80
|
+
mean_reps=n_total / n_groups,
|
|
81
|
+
icc=icc,
|
|
82
|
+
sd_between=math.sqrt(var_between),
|
|
83
|
+
sd_within=math.sqrt(var_within),
|
|
84
|
+
exact_agreement=exact,
|
|
85
|
+
)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: abeval
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A/B-test statistics for LLM evals: error bars, paired comparisons, and sample-size planning
|
|
5
|
+
Project-URL: Homepage, https://github.com/mohammadi-hadi/abeval
|
|
6
|
+
Project-URL: Calculator, https://mohammadi.cv/abeval/
|
|
7
|
+
Author: Hadi Mohammadi
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ab-testing,evaluation,llm,sample-size,significance,statistics
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# abeval
|
|
25
|
+
|
|
26
|
+
A/B-test statistics for LLM evals.
|
|
27
|
+
|
|
28
|
+
Eval scores are sample estimates, but they're routinely reported as exact
|
|
29
|
+
numbers: "our prompt scores 76.5%, the old one 63.5%, ship it." On 200 items
|
|
30
|
+
much smaller gaps than that are pure noise. abeval treats an eval like an A/B
|
|
31
|
+
test — confidence intervals for a single run, paired significance tests for
|
|
32
|
+
comparing two runs on the same items, and sample-size planning *before* you
|
|
33
|
+
spend on inference. Standard library only, no dependencies.
|
|
34
|
+
|
|
35
|
+
**Sample-size calculator:** https://mohammadi.cv/abeval/
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pip install abeval
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or from source: `git clone https://github.com/mohammadi-hadi/abeval && cd abeval && make install`.
|
|
44
|
+
|
|
45
|
+
## Is my new prompt actually better?
|
|
46
|
+
|
|
47
|
+
Point `abeval compare` at two JSONL result files that share item ids:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
$ abeval compare examples/run_a.jsonl examples/run_b.jsonl
|
|
51
|
+
A: 63.5% B: 76.5% (n=200 paired items)
|
|
52
|
+
B - A: +13.0% [4.5%, 21.5%] (95% CI)
|
|
53
|
+
p (sign-flip permutation): 0.0041 p (paired t): 0.0034
|
|
54
|
+
discordant items: B-only wins 53, A-only wins 27 p (exact McNemar): 0.0049
|
|
55
|
+
verdict: significant at the chosen level
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The comparison is **paired**: both runs are scored on the same items, so
|
|
59
|
+
item difficulty cancels out and you detect much smaller differences than two
|
|
60
|
+
independent runs would allow. Items present in only one run are dropped (and
|
|
61
|
+
reported).
|
|
62
|
+
|
|
63
|
+
Every command takes `--json` for machine-readable output, and `--seed` makes
|
|
64
|
+
the resampling reproducible.
|
|
65
|
+
|
|
66
|
+
## Error bars for one run
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
$ abeval ci examples/run_a.jsonl
|
|
70
|
+
score: 63.5% [56.6%, 69.9%] (n=200, 95% CI, wilson)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Binary metrics get a Wilson score interval, continuous metrics a t interval
|
|
74
|
+
(or `--bootstrap`). If your items aren't independent — 5 questions generated
|
|
75
|
+
from each source document, say — pass `--cluster-key document` and the
|
|
76
|
+
interval widens to account for within-cluster correlation.
|
|
77
|
+
|
|
78
|
+
## How many items do I need?
|
|
79
|
+
|
|
80
|
+
Run this *before* the eval, not after:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
$ abeval power --baseline 0.75 --delta 0.03
|
|
84
|
+
to detect 3.0% difference from a 75.0% baseline (corr=0.5):
|
|
85
|
+
n = 1568 paired items (80% power, 95% confidence)
|
|
86
|
+
with 100 items you can detect >= 11.9%
|
|
87
|
+
with 200 items you can detect >= 8.4%
|
|
88
|
+
with 500 items you can detect >= 5.3%
|
|
89
|
+
with 1000 items you can detect >= 3.8%
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The lesson generalizes: a 200-item eval cannot see a 3-point improvement.
|
|
93
|
+
`--corr` is the item-level correlation between the two runs' outcomes (0.5 is
|
|
94
|
+
a reasonable default for two variants of the same system; higher correlation
|
|
95
|
+
means fewer items needed). There's an interactive version at
|
|
96
|
+
https://mohammadi.cv/abeval/.
|
|
97
|
+
|
|
98
|
+
## How noisy is my judge?
|
|
99
|
+
|
|
100
|
+
If an LLM judge scores the same item differently on repeat calls, that noise
|
|
101
|
+
eats your statistical power. Feed repeated judgments to `reliability`:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
$ abeval reliability examples/judge_repeats.jsonl
|
|
105
|
+
40 items, 120 judgments (3.0 per item)
|
|
106
|
+
ICC (signal share of variance): 0.764
|
|
107
|
+
between-item sd: 1.708 judge noise sd: 0.9487
|
|
108
|
+
exact agreement across repeats: 15.0%
|
|
109
|
+
repeats per item to push judge noise under 10%: 3
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
ICC is the share of score variance that is real item signal rather than judge
|
|
113
|
+
noise, from a one-way random-effects decomposition. The last line answers the
|
|
114
|
+
practical question: how many repeat judgments to average per item.
|
|
115
|
+
|
|
116
|
+
## Data format
|
|
117
|
+
|
|
118
|
+
JSONL, one item per line, any extra fields ignored:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{"id": "item-001", "score": 1}
|
|
122
|
+
{"id": "item-002", "score": 0, "category": "reasoning"}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`--metric` and `--id-key` rename the fields; booleans are accepted as 0/1.
|
|
126
|
+
The files in `examples/` are generated by `examples/make_fixtures.py`
|
|
127
|
+
(seeded, so they're reproducible).
|
|
128
|
+
|
|
129
|
+
## Python API
|
|
130
|
+
|
|
131
|
+
Everything the CLI does is a plain function:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from abeval import paired_compare, proportion_ci, sample_size, sd_diff_from_rates
|
|
135
|
+
|
|
136
|
+
proportion_ci(127, 200) # Wilson interval, level=0.95
|
|
137
|
+
result = paired_compare(scores_a, scores_b, seed=0)
|
|
138
|
+
result.diff, result.ci_lo, result.ci_hi, result.p_permutation
|
|
139
|
+
|
|
140
|
+
sd = sd_diff_from_rates(0.75, 0.78, corr=0.5)
|
|
141
|
+
sample_size(0.03, sd) # -> 1568 paired items
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## What's inside
|
|
145
|
+
|
|
146
|
+
| Question | Method |
|
|
147
|
+
|---|---|
|
|
148
|
+
| CI for a pass rate | Wilson score interval |
|
|
149
|
+
| CI for a mean score | Student t, percentile bootstrap, or cluster-robust t |
|
|
150
|
+
| Is B better than A? | Sign-flip permutation test on paired differences (primary), paired t, exact McNemar on discordant pairs (binary) |
|
|
151
|
+
| How many items? | Normal-approximation power for the paired design, with a Monte-Carlo check (`power_simulated`) |
|
|
152
|
+
| Judge noise | One-way random-effects ICC with unbalanced-design correction |
|
|
153
|
+
|
|
154
|
+
The permutation p-value is the primary test: it's exact under item
|
|
155
|
+
exchangeability and makes no normality assumption. The paired t and McNemar
|
|
156
|
+
values are printed alongside because reviewers ask for them.
|
|
157
|
+
|
|
158
|
+
The approach follows Miller,
|
|
159
|
+
["Adding Error Bars to Evals"](https://arxiv.org/abs/2411.00640) (2024) —
|
|
160
|
+
report standard errors, use paired designs, plan sample sizes — packaged as a
|
|
161
|
+
tool you can point at result files.
|
|
162
|
+
|
|
163
|
+
## Honest limitations
|
|
164
|
+
|
|
165
|
+
- The power formulas use the normal approximation; for very small samples or
|
|
166
|
+
rates near 0/1 trust `power_simulated` over the closed form.
|
|
167
|
+
- Cluster-robust intervals are approximate with fewer than ~20 clusters.
|
|
168
|
+
- Multiple-comparison correction is on you: if you compare ten prompts and
|
|
169
|
+
ship the best p-value, it's inflated (a `sweep` command with Bonferroni
|
|
170
|
+
correction is on the roadmap).
|
|
171
|
+
- This is not a stats library. If you need regressions or GLMs, use
|
|
172
|
+
statsmodels; abeval covers the eval loop with zero dependencies.
|
|
173
|
+
|
|
174
|
+
## Sponsoring
|
|
175
|
+
|
|
176
|
+
abeval is MIT-licensed and dependency-free, and it stays that way. Sponsoring
|
|
177
|
+
funds the roadmap below and the maintenance time to keep the statistics
|
|
178
|
+
trustworthy. Sponsors are credited in release notes and vote on what lands
|
|
179
|
+
next: [GitHub Sponsors](https://github.com/sponsors/mohammadi-hadi).
|
|
180
|
+
|
|
181
|
+
## Roadmap
|
|
182
|
+
|
|
183
|
+
- `sweep`: compare N runs against a baseline with multiple-comparison
|
|
184
|
+
correction.
|
|
185
|
+
- Confidence intervals for pairwise win rates (Bradley-Terry) for
|
|
186
|
+
head-to-head judge outputs.
|
|
187
|
+
- Variance-reduction guidance: how much power you buy by averaging k judge
|
|
188
|
+
repeats, given your measured ICC.
|
|
189
|
+
|
|
190
|
+
## Related projects
|
|
191
|
+
|
|
192
|
+
- [judgewatch](https://github.com/mohammadi-hadi/judgewatch) — monthly bias
|
|
193
|
+
audits of LLM judges; abeval tells you whether this month's change is real.
|
|
194
|
+
- [judgekit](https://github.com/mohammadi-hadi/judgekit) — audit your own
|
|
195
|
+
judge pipelines.
|
|
196
|
+
- [trajectory-judge](https://github.com/mohammadi-hadi/trajectory-judge) —
|
|
197
|
+
evaluating agent trajectories with LLM judges.
|
|
198
|
+
|
|
199
|
+
## License
|
|
200
|
+
|
|
201
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
abeval/__init__.py,sha256=9N4TC2HGKp5MR1K2mYDdrne986Vmr0AMg7pA_ndIkqE,902
|
|
2
|
+
abeval/__main__.py,sha256=_gXHiC5klfM8bIZJkggW2pd6sxL5S5hEbWv6XIS5bMg,8118
|
|
3
|
+
abeval/_dist.py,sha256=7xGFRocjFR_vKidhUN_ILMiH9qbsNYjr8HtaABcmQdI,2899
|
|
4
|
+
abeval/ci.py,sha256=RPTAOHTh6lYQMSiC4_4XXQFn9YCMOxYu0bsq3Dt897w,3737
|
|
5
|
+
abeval/compare.py,sha256=zVfDlBVDyXsVFoYiqgEkfL-pIr-9pqJl1V4trACQA4I,3877
|
|
6
|
+
abeval/data.py,sha256=-gnQoxhApCWduPXR1lH3dP8rjCxwD2QaejvE_FNL90s,1955
|
|
7
|
+
abeval/power.py,sha256=SmYBmx9BdF3CIEUd9Dxq2klSvPrKAqY9QqdFGawpfpI,4054
|
|
8
|
+
abeval/reliability.py,sha256=MKJOcWGB8DnxZYd-cJzR8ZXCCrfP7sbLv8wXOl25zu0,3200
|
|
9
|
+
abeval-0.1.0.dist-info/METADATA,sha256=_MgYaOfWv2OiibsmCuXS4SFB9-V7Urb1GYx0UNyhzYI,7530
|
|
10
|
+
abeval-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
abeval-0.1.0.dist-info/entry_points.txt,sha256=7yE5AcyOrFQsKiB0VlKzOhcbt7celHftfkCSYP1OG-g,48
|
|
12
|
+
abeval-0.1.0.dist-info/licenses/LICENSE,sha256=F-4iu05PfGFM6P0UkwKzuusSISVTwYeaC14QMud0b9g,1071
|
|
13
|
+
abeval-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hadi Mohammadi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|