errorbars 0.1.2__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.
- errorbars/__init__.py +28 -0
- errorbars/adapters/__init__.py +1 -0
- errorbars/adapters/inspect_ai.py +83 -0
- errorbars/adapters/lm_eval.py +103 -0
- errorbars/cli.py +426 -0
- errorbars/compare.py +178 -0
- errorbars/io.py +197 -0
- errorbars/leaderboard.py +181 -0
- errorbars/plot.py +141 -0
- errorbars/power.py +137 -0
- errorbars/py.typed +0 -0
- errorbars/stats.py +329 -0
- errorbars-0.1.2.dist-info/METADATA +297 -0
- errorbars-0.1.2.dist-info/RECORD +17 -0
- errorbars-0.1.2.dist-info/WHEEL +4 -0
- errorbars-0.1.2.dist-info/entry_points.txt +2 -0
- errorbars-0.1.2.dist-info/licenses/LICENSE +21 -0
errorbars/compare.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Paired comparisons between two models on the same questions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numpy.typing import ArrayLike
|
|
11
|
+
|
|
12
|
+
from errorbars.stats import cluster_robust_se, is_binary, t_for_confidence, t_two_sided_p
|
|
13
|
+
|
|
14
|
+
__all__ = ["PairedComparison", "paired_compare", "McNemarResult", "mcnemar_exact"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class PairedComparison:
|
|
19
|
+
"""Result of a paired comparison between model A and model B."""
|
|
20
|
+
|
|
21
|
+
mean_a: float
|
|
22
|
+
mean_b: float
|
|
23
|
+
mean_diff: float
|
|
24
|
+
se_paired: float
|
|
25
|
+
ci_low: float
|
|
26
|
+
ci_high: float
|
|
27
|
+
p_value: float
|
|
28
|
+
correlation: float
|
|
29
|
+
se_unpaired: float
|
|
30
|
+
variance_reduction: float
|
|
31
|
+
n: int
|
|
32
|
+
confidence: float
|
|
33
|
+
se_clustered: float | None = None
|
|
34
|
+
ci_low_clustered: float | None = None
|
|
35
|
+
ci_high_clustered: float | None = None
|
|
36
|
+
p_value_clustered: float | None = None
|
|
37
|
+
mcnemar: McNemarResult | None = None
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> dict[str, Any]:
|
|
40
|
+
d: dict[str, Any] = {
|
|
41
|
+
"mean_a": self.mean_a,
|
|
42
|
+
"mean_b": self.mean_b,
|
|
43
|
+
"mean_diff": self.mean_diff,
|
|
44
|
+
"se_paired": self.se_paired,
|
|
45
|
+
"ci_low": self.ci_low,
|
|
46
|
+
"ci_high": self.ci_high,
|
|
47
|
+
"p_value": self.p_value,
|
|
48
|
+
"correlation": self.correlation,
|
|
49
|
+
"se_unpaired": self.se_unpaired,
|
|
50
|
+
"variance_reduction": self.variance_reduction,
|
|
51
|
+
"n": self.n,
|
|
52
|
+
"confidence": self.confidence,
|
|
53
|
+
}
|
|
54
|
+
if self.se_clustered is not None:
|
|
55
|
+
d["se_clustered"] = self.se_clustered
|
|
56
|
+
d["ci_low_clustered"] = self.ci_low_clustered
|
|
57
|
+
d["ci_high_clustered"] = self.ci_high_clustered
|
|
58
|
+
d["p_value_clustered"] = self.p_value_clustered
|
|
59
|
+
if self.mcnemar is not None:
|
|
60
|
+
d["mcnemar"] = self.mcnemar.as_dict()
|
|
61
|
+
return d
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def paired_compare(
|
|
65
|
+
scores_a: ArrayLike,
|
|
66
|
+
scores_b: ArrayLike,
|
|
67
|
+
clusters: ArrayLike | None = None,
|
|
68
|
+
confidence: float = 0.95,
|
|
69
|
+
) -> PairedComparison:
|
|
70
|
+
"""Paired difference test: mean(A) - mean(B) over the same questions.
|
|
71
|
+
|
|
72
|
+
Reports the standard paired t-test SE/CI/p-value (Student t, n - 1
|
|
73
|
+
degrees of freedom), the correlation between the two models'
|
|
74
|
+
per-question scores, and how much pairing shrank the SE relative to an
|
|
75
|
+
unpaired (independent two-sample) SE at the same n. When ``clusters`` is
|
|
76
|
+
given, also reports a cluster-robust paired SE/CI/p-value (see
|
|
77
|
+
``errorbars.stats.cluster_robust_se``) on t with G - 1 degrees of
|
|
78
|
+
freedom for G clusters, the usual reference for a clustered mean (Cameron
|
|
79
|
+
& Miller 2015). When both score vectors are binary, also runs McNemar's
|
|
80
|
+
exact test.
|
|
81
|
+
"""
|
|
82
|
+
a = np.asarray(scores_a, dtype=float)
|
|
83
|
+
b = np.asarray(scores_b, dtype=float)
|
|
84
|
+
if a.shape != b.shape:
|
|
85
|
+
raise ValueError("scores_a and scores_b must have the same length (paired)")
|
|
86
|
+
n = a.size
|
|
87
|
+
if n < 2:
|
|
88
|
+
raise ValueError("need at least 2 paired observations")
|
|
89
|
+
|
|
90
|
+
diff = a - b
|
|
91
|
+
mean_diff = float(diff.mean())
|
|
92
|
+
sd_diff = float(diff.std(ddof=1))
|
|
93
|
+
se_paired = sd_diff / math.sqrt(n)
|
|
94
|
+
t_crit = t_for_confidence(confidence, n - 1)
|
|
95
|
+
ci_low, ci_high = mean_diff - t_crit * se_paired, mean_diff + t_crit * se_paired
|
|
96
|
+
t_stat = mean_diff / se_paired if se_paired > 0 else 0.0
|
|
97
|
+
p_value = t_two_sided_p(t_stat, n - 1) if se_paired > 0 else 1.0
|
|
98
|
+
|
|
99
|
+
var_a, var_b = float(a.var(ddof=1)), float(b.var(ddof=1))
|
|
100
|
+
corr = float(np.corrcoef(a, b)[0, 1]) if var_a > 0 and var_b > 0 else 0.0
|
|
101
|
+
var_unpaired = var_a + var_b
|
|
102
|
+
se_unpaired = math.sqrt(var_unpaired / n)
|
|
103
|
+
variance_reduction = 1.0 - (se_paired**2 / se_unpaired**2) if se_unpaired > 0 else 0.0
|
|
104
|
+
|
|
105
|
+
se_clustered: float | None = None
|
|
106
|
+
ci_low_c: float | None = None
|
|
107
|
+
ci_high_c: float | None = None
|
|
108
|
+
p_value_c: float | None = None
|
|
109
|
+
if clusters is not None:
|
|
110
|
+
cluster_arr = np.asarray(clusters)
|
|
111
|
+
se_clustered = cluster_robust_se(diff, cluster_arr)
|
|
112
|
+
n_clusters = len(set(cluster_arr.tolist()))
|
|
113
|
+
# With a single cluster cluster_robust_se falls back to the plain SE, so its reference is n - 1.
|
|
114
|
+
dof_c = n_clusters - 1 if n_clusters > 1 else n - 1
|
|
115
|
+
t_crit_c = t_for_confidence(confidence, dof_c)
|
|
116
|
+
ci_low_c = mean_diff - t_crit_c * se_clustered
|
|
117
|
+
ci_high_c = mean_diff + t_crit_c * se_clustered
|
|
118
|
+
t_stat_c = mean_diff / se_clustered if se_clustered > 0 else 0.0
|
|
119
|
+
p_value_c = float(t_two_sided_p(t_stat_c, dof_c)) if se_clustered > 0 else 1.0
|
|
120
|
+
|
|
121
|
+
scores_list_a: list[float] = a.tolist()
|
|
122
|
+
scores_list_b: list[float] = b.tolist()
|
|
123
|
+
mcnemar = mcnemar_exact(scores_list_a, scores_list_b) if is_binary(a) and is_binary(b) else None
|
|
124
|
+
|
|
125
|
+
return PairedComparison(
|
|
126
|
+
mean_a=float(a.mean()),
|
|
127
|
+
mean_b=float(b.mean()),
|
|
128
|
+
mean_diff=mean_diff,
|
|
129
|
+
se_paired=se_paired,
|
|
130
|
+
ci_low=ci_low,
|
|
131
|
+
ci_high=ci_high,
|
|
132
|
+
p_value=float(p_value),
|
|
133
|
+
correlation=corr,
|
|
134
|
+
se_unpaired=se_unpaired,
|
|
135
|
+
variance_reduction=float(variance_reduction),
|
|
136
|
+
n=n,
|
|
137
|
+
confidence=confidence,
|
|
138
|
+
se_clustered=se_clustered,
|
|
139
|
+
ci_low_clustered=ci_low_c,
|
|
140
|
+
ci_high_clustered=ci_high_c,
|
|
141
|
+
p_value_clustered=p_value_c,
|
|
142
|
+
mcnemar=mcnemar,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class McNemarResult:
|
|
148
|
+
"""Exact McNemar test on discordant pairs of a 2x2 paired-binary table."""
|
|
149
|
+
|
|
150
|
+
n01: int # A wrong, B right
|
|
151
|
+
n10: int # A right, B wrong
|
|
152
|
+
p_value: float
|
|
153
|
+
|
|
154
|
+
def as_dict(self) -> dict[str, Any]:
|
|
155
|
+
return {"n01": self.n01, "n10": self.n10, "p_value": self.p_value}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def mcnemar_exact(scores_a: ArrayLike, scores_b: ArrayLike) -> McNemarResult:
|
|
159
|
+
"""Exact (binomial) two-sided McNemar test for paired binary outcomes.
|
|
160
|
+
|
|
161
|
+
Uses only the discordant pairs b = #(A=0,B=1), c = #(A=1,B=0); under the
|
|
162
|
+
null they are Binomial(b+c, 0.5). Reference: McNemar (1947); the exact
|
|
163
|
+
version is preferred over the chi-square approximation when b+c is
|
|
164
|
+
small. Matches ``statsmodels.stats.contingency_tables.mcnemar(...,
|
|
165
|
+
exact=True)``.
|
|
166
|
+
"""
|
|
167
|
+
a = np.asarray(scores_a)
|
|
168
|
+
b = np.asarray(scores_b)
|
|
169
|
+
n01 = int(np.sum((a == 0) & (b == 1)))
|
|
170
|
+
n10 = int(np.sum((a == 1) & (b == 0)))
|
|
171
|
+
total = n01 + n10
|
|
172
|
+
if total == 0:
|
|
173
|
+
return McNemarResult(n01, n10, 1.0)
|
|
174
|
+
k = min(n01, n10)
|
|
175
|
+
# two-sided exact binomial test, p=0.5: sum both tails via symmetry
|
|
176
|
+
tail = sum(math.comb(total, i) for i in range(0, k + 1)) / (2**total)
|
|
177
|
+
p_value = min(1.0, 2 * tail)
|
|
178
|
+
return McNemarResult(n01, n10, float(p_value))
|
errorbars/io.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Loading per-item eval scores from CSV, JSONL, or a pandas DataFrame.
|
|
2
|
+
|
|
3
|
+
The canonical shape is "long format": one row per (question, model[, sample])
|
|
4
|
+
with columns ``question_id``, ``model``, ``score``, and optional
|
|
5
|
+
``cluster_id`` / ``sample``. Column names are configurable so you don't have
|
|
6
|
+
to reshape your existing logs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
__all__ = ["EvalData", "ColumnMap", "load_csv", "load_jsonl", "load_dataframe", "write_csv"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ColumnMap:
|
|
24
|
+
"""Names of the columns in your source data, if not the defaults."""
|
|
25
|
+
|
|
26
|
+
question_id: str = "question_id"
|
|
27
|
+
model: str = "model"
|
|
28
|
+
score: str = "score"
|
|
29
|
+
cluster_id: str | None = "cluster_id"
|
|
30
|
+
sample: str | None = "sample"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class EvalData:
|
|
35
|
+
"""Normalized long-format eval records, one row per observation."""
|
|
36
|
+
|
|
37
|
+
question_id: list[str]
|
|
38
|
+
model: list[str]
|
|
39
|
+
score: list[float]
|
|
40
|
+
cluster_id: list[str] | None = None
|
|
41
|
+
sample: list[str] | None = None
|
|
42
|
+
columns: ColumnMap = field(default_factory=ColumnMap)
|
|
43
|
+
|
|
44
|
+
def __len__(self) -> int:
|
|
45
|
+
return len(self.score)
|
|
46
|
+
|
|
47
|
+
def models(self) -> list[str]:
|
|
48
|
+
seen: dict[str, None] = {}
|
|
49
|
+
for m in self.model:
|
|
50
|
+
seen.setdefault(m, None)
|
|
51
|
+
return list(seen)
|
|
52
|
+
|
|
53
|
+
def filter_model(self, model: str) -> EvalData:
|
|
54
|
+
idx = [i for i, m in enumerate(self.model) if m == model]
|
|
55
|
+
return EvalData(
|
|
56
|
+
question_id=[self.question_id[i] for i in idx],
|
|
57
|
+
model=[self.model[i] for i in idx],
|
|
58
|
+
score=[self.score[i] for i in idx],
|
|
59
|
+
cluster_id=[self.cluster_id[i] for i in idx] if self.cluster_id else None,
|
|
60
|
+
sample=[self.sample[i] for i in idx] if self.sample else None,
|
|
61
|
+
columns=self.columns,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def scores_by_question(self) -> dict[str, float]:
|
|
65
|
+
"""Mean score per question_id (collapses repeated samples)."""
|
|
66
|
+
totals: dict[str, list[float]] = {}
|
|
67
|
+
for qid, s in zip(self.question_id, self.score, strict=True):
|
|
68
|
+
totals.setdefault(qid, []).append(s)
|
|
69
|
+
return {qid: sum(v) / len(v) for qid, v in totals.items()}
|
|
70
|
+
|
|
71
|
+
def cluster_by_question(self) -> dict[str, str]:
|
|
72
|
+
if not self.cluster_id:
|
|
73
|
+
return {}
|
|
74
|
+
out: dict[str, str] = {}
|
|
75
|
+
for qid, c in zip(self.question_id, self.cluster_id, strict=True):
|
|
76
|
+
out.setdefault(qid, c)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _coerce_score(raw: Any, row: int) -> float:
|
|
81
|
+
if isinstance(raw, bool):
|
|
82
|
+
return float(raw)
|
|
83
|
+
try:
|
|
84
|
+
value = float(raw)
|
|
85
|
+
except (TypeError, ValueError) as exc:
|
|
86
|
+
raise ValueError(f"row {row}: could not parse score value {raw!r} as a number") from exc
|
|
87
|
+
# A NaN would propagate into every mean, CI and p-value (and sort to the top of a leaderboard);
|
|
88
|
+
# a missing or failed grade has to be decided on explicitly, not averaged in as "not a number".
|
|
89
|
+
if not math.isfinite(value):
|
|
90
|
+
raise ValueError(f"row {row}: score {raw!r} is not a finite number")
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _from_records(records: Iterable[dict[str, Any]], columns: ColumnMap) -> EvalData:
|
|
95
|
+
question_id: list[str] = []
|
|
96
|
+
model: list[str] = []
|
|
97
|
+
score: list[float] = []
|
|
98
|
+
cluster_id: list[str] | None = [] if columns.cluster_id else None
|
|
99
|
+
sample: list[str] | None = [] if columns.sample else None
|
|
100
|
+
|
|
101
|
+
n = 0
|
|
102
|
+
seen: dict[tuple[str, str, str], int] = {}
|
|
103
|
+
for row in records:
|
|
104
|
+
n += 1
|
|
105
|
+
if columns.question_id not in row:
|
|
106
|
+
raise ValueError(f"missing required column '{columns.question_id}' in row {n}")
|
|
107
|
+
if columns.model not in row:
|
|
108
|
+
raise ValueError(f"missing required column '{columns.model}' in row {n}")
|
|
109
|
+
if columns.score not in row:
|
|
110
|
+
raise ValueError(f"missing required column '{columns.score}' in row {n}")
|
|
111
|
+
question_id.append(str(row[columns.question_id]))
|
|
112
|
+
model.append(str(row[columns.model]))
|
|
113
|
+
score.append(_coerce_score(row[columns.score], n))
|
|
114
|
+
if cluster_id is not None and columns.cluster_id is not None:
|
|
115
|
+
cluster_id.append(str(row.get(columns.cluster_id, question_id[-1])))
|
|
116
|
+
sample_id = str(row.get(columns.sample, "0")) if columns.sample is not None else "0"
|
|
117
|
+
if sample is not None:
|
|
118
|
+
sample.append(sample_id)
|
|
119
|
+
# The same (model, question, sample) twice would be counted as two questions, inflating n and
|
|
120
|
+
# shrinking every standard error. Repeated generations need distinct sample ids.
|
|
121
|
+
key = (model[-1], question_id[-1], sample_id)
|
|
122
|
+
if key in seen:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"row {n}: model {key[0]!r} already has a score for question {key[1]!r} (row {seen[key]}); "
|
|
125
|
+
f"give repeated generations distinct values in a '{columns.sample or 'sample'}' column"
|
|
126
|
+
)
|
|
127
|
+
seen[key] = n
|
|
128
|
+
|
|
129
|
+
if n == 0:
|
|
130
|
+
raise ValueError("no rows found in input data")
|
|
131
|
+
|
|
132
|
+
return EvalData(question_id, model, score, cluster_id, sample, columns)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def load_csv(path: str | Path, columns: ColumnMap | None = None) -> EvalData:
|
|
136
|
+
"""Load per-item scores from a CSV file."""
|
|
137
|
+
columns = columns or ColumnMap()
|
|
138
|
+
with open(path, newline="", encoding="utf-8") as f:
|
|
139
|
+
reader = csv.DictReader(f)
|
|
140
|
+
if reader.fieldnames is None:
|
|
141
|
+
raise ValueError(f"{path}: empty or headerless CSV")
|
|
142
|
+
return _from_records(reader, columns)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_jsonl(path: str | Path, columns: ColumnMap | None = None) -> EvalData:
|
|
146
|
+
"""Load per-item scores from a JSON Lines file (one record per line)."""
|
|
147
|
+
columns = columns or ColumnMap()
|
|
148
|
+
records: list[dict[str, Any]] = []
|
|
149
|
+
with open(path, encoding="utf-8") as f:
|
|
150
|
+
for lineno, line in enumerate(f, start=1):
|
|
151
|
+
line = line.strip()
|
|
152
|
+
if not line:
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
records.append(json.loads(line))
|
|
156
|
+
except json.JSONDecodeError as exc:
|
|
157
|
+
raise ValueError(f"{path}:{lineno}: invalid JSON: {exc}") from exc
|
|
158
|
+
if not records:
|
|
159
|
+
raise ValueError(f"{path}: no records found")
|
|
160
|
+
return _from_records(records, columns)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def load_dataframe(df: Any, columns: ColumnMap | None = None) -> EvalData:
|
|
164
|
+
"""Load per-item scores from a pandas DataFrame."""
|
|
165
|
+
columns = columns or ColumnMap()
|
|
166
|
+
records = df.to_dict(orient="records")
|
|
167
|
+
return _from_records(records, columns)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def write_csv(data: EvalData, path: str | Path) -> None:
|
|
171
|
+
"""Write an ``EvalData`` back out as a canonical long-format CSV.
|
|
172
|
+
|
|
173
|
+
Used by ``errorbars import`` to turn an adapter's output into a file
|
|
174
|
+
``summarize``/``compare``/``leaderboard`` can load directly. Only
|
|
175
|
+
includes ``cluster_id`` / ``sample`` columns when the data actually has
|
|
176
|
+
them.
|
|
177
|
+
"""
|
|
178
|
+
fieldnames = ["question_id", "model", "score"]
|
|
179
|
+
if data.cluster_id:
|
|
180
|
+
fieldnames.append("cluster_id")
|
|
181
|
+
if data.sample:
|
|
182
|
+
fieldnames.append("sample")
|
|
183
|
+
|
|
184
|
+
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
185
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
186
|
+
writer.writeheader()
|
|
187
|
+
for i in range(len(data)):
|
|
188
|
+
row: dict[str, Any] = {
|
|
189
|
+
"question_id": data.question_id[i],
|
|
190
|
+
"model": data.model[i],
|
|
191
|
+
"score": data.score[i],
|
|
192
|
+
}
|
|
193
|
+
if data.cluster_id:
|
|
194
|
+
row["cluster_id"] = data.cluster_id[i]
|
|
195
|
+
if data.sample:
|
|
196
|
+
row["sample"] = data.sample[i]
|
|
197
|
+
writer.writerow(row)
|
errorbars/leaderboard.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Multi-model leaderboards with paired pairwise tests and Holm correction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from itertools import combinations
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from errorbars.compare import PairedComparison, paired_compare
|
|
10
|
+
from errorbars.io import EvalData
|
|
11
|
+
from errorbars.stats import MeanEstimate, is_binary, mean_ci_clt, wilson_ci
|
|
12
|
+
|
|
13
|
+
__all__ = ["LeaderboardEntry", "PairwiseResult", "Leaderboard", "build_leaderboard", "holm_correction"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def holm_correction(p_values: list[float]) -> list[float]:
|
|
17
|
+
"""Holm-Bonferroni step-down adjusted p-values (Holm, 1979).
|
|
18
|
+
|
|
19
|
+
Controls the family-wise error rate without assuming independence,
|
|
20
|
+
less conservative than plain Bonferroni. Matches
|
|
21
|
+
``statsmodels.stats.multitest.multipletests(p, method="holm")``.
|
|
22
|
+
"""
|
|
23
|
+
m = len(p_values)
|
|
24
|
+
order = sorted(range(m), key=lambda i: p_values[i])
|
|
25
|
+
adjusted = [0.0] * m
|
|
26
|
+
running_max = 0.0
|
|
27
|
+
for rank, i in enumerate(order):
|
|
28
|
+
raw = (m - rank) * p_values[i]
|
|
29
|
+
running_max = max(running_max, raw)
|
|
30
|
+
adjusted[i] = min(1.0, running_max)
|
|
31
|
+
return adjusted
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LeaderboardEntry:
|
|
36
|
+
model: str
|
|
37
|
+
mean: float
|
|
38
|
+
se: float
|
|
39
|
+
ci_low: float
|
|
40
|
+
ci_high: float
|
|
41
|
+
n: int
|
|
42
|
+
method: str
|
|
43
|
+
|
|
44
|
+
def as_dict(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"model": self.model,
|
|
47
|
+
"mean": self.mean,
|
|
48
|
+
"se": self.se,
|
|
49
|
+
"ci_low": self.ci_low,
|
|
50
|
+
"ci_high": self.ci_high,
|
|
51
|
+
"n": self.n,
|
|
52
|
+
"method": self.method,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class PairwiseResult:
|
|
58
|
+
model_a: str
|
|
59
|
+
model_b: str
|
|
60
|
+
comparison: PairedComparison
|
|
61
|
+
p_holm: float
|
|
62
|
+
|
|
63
|
+
def as_dict(self) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"model_a": self.model_a,
|
|
66
|
+
"model_b": self.model_b,
|
|
67
|
+
"mean_diff": self.comparison.mean_diff,
|
|
68
|
+
"p_value": self.comparison.p_value,
|
|
69
|
+
"p_holm": self.p_holm,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Leaderboard:
|
|
75
|
+
entries: list[LeaderboardEntry] # sorted by mean, descending
|
|
76
|
+
pairwise: list[PairwiseResult]
|
|
77
|
+
groups: list[list[str]] # each group: models not significantly different (Holm alpha)
|
|
78
|
+
alpha: float
|
|
79
|
+
|
|
80
|
+
def as_dict(self) -> dict[str, Any]:
|
|
81
|
+
return {
|
|
82
|
+
"entries": [e.as_dict() for e in self.entries],
|
|
83
|
+
"pairwise": [p.as_dict() for p in self.pairwise],
|
|
84
|
+
"groups": self.groups,
|
|
85
|
+
"alpha": self.alpha,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _maximal_cliques(nodes: list[str], edges: set[frozenset[str]]) -> list[list[str]]:
|
|
90
|
+
"""Bron-Kerbosch without pivoting; fine for leaderboard-sized graphs."""
|
|
91
|
+
|
|
92
|
+
def neighbors(v: str) -> set[str]:
|
|
93
|
+
return {u for u in nodes if u != v and frozenset((u, v)) in edges}
|
|
94
|
+
|
|
95
|
+
adj = {v: neighbors(v) for v in nodes}
|
|
96
|
+
cliques: list[set[str]] = []
|
|
97
|
+
|
|
98
|
+
def bron_kerbosch(r: set[str], p: set[str], x: set[str]) -> None:
|
|
99
|
+
if not p and not x:
|
|
100
|
+
cliques.append(r)
|
|
101
|
+
return
|
|
102
|
+
for v in list(p):
|
|
103
|
+
bron_kerbosch(r | {v}, p & adj[v], x & adj[v])
|
|
104
|
+
p = p - {v}
|
|
105
|
+
x = x | {v}
|
|
106
|
+
|
|
107
|
+
bron_kerbosch(set(), set(nodes), set())
|
|
108
|
+
# sort each clique by the leaderboard's original node order for stable display
|
|
109
|
+
order = {v: i for i, v in enumerate(nodes)}
|
|
110
|
+
cliques_sorted = [sorted(c, key=lambda v: order[v]) for c in cliques]
|
|
111
|
+
cliques_sorted.sort(key=lambda c: (order[c[0]], -len(c)))
|
|
112
|
+
return cliques_sorted
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_leaderboard(
|
|
116
|
+
data: EvalData,
|
|
117
|
+
confidence: float = 0.95,
|
|
118
|
+
alpha: float = 0.05,
|
|
119
|
+
use_wilson_below_n: int = 30,
|
|
120
|
+
) -> Leaderboard:
|
|
121
|
+
"""Build a leaderboard: per-model CIs, Holm-corrected pairwise tests, groups.
|
|
122
|
+
|
|
123
|
+
``use_wilson_below_n``: for binary scores with fewer than this many
|
|
124
|
+
questions, per-model CIs use the Wilson interval instead of the CLT
|
|
125
|
+
interval (see ``errorbars.stats.wilson_ci``).
|
|
126
|
+
"""
|
|
127
|
+
models = data.models()
|
|
128
|
+
if len(models) < 2:
|
|
129
|
+
raise ValueError("leaderboard needs at least 2 models")
|
|
130
|
+
|
|
131
|
+
per_model_scores: dict[str, list[float]] = {m: data.filter_model(m).score for m in models}
|
|
132
|
+
|
|
133
|
+
entries: list[LeaderboardEntry] = []
|
|
134
|
+
for m in models:
|
|
135
|
+
scores = per_model_scores[m]
|
|
136
|
+
est = _summarize_mean(scores, confidence, use_wilson_below_n)
|
|
137
|
+
entries.append(LeaderboardEntry(m, est.mean, est.se, est.ci_low, est.ci_high, est.n, est.method))
|
|
138
|
+
entries.sort(key=lambda e: e.mean, reverse=True)
|
|
139
|
+
order = [e.model for e in entries]
|
|
140
|
+
|
|
141
|
+
# Align questions for paired tests: use the intersection of question_ids
|
|
142
|
+
# common to both models, sorted for determinism.
|
|
143
|
+
qmap = {m: data.filter_model(m).scores_by_question() for m in models}
|
|
144
|
+
cluster_of_q = data.cluster_by_question()
|
|
145
|
+
|
|
146
|
+
pairwise: list[PairwiseResult] = []
|
|
147
|
+
raw_p: list[float] = []
|
|
148
|
+
pair_keys: list[tuple[str, str]] = []
|
|
149
|
+
for a, b in combinations(order, 2):
|
|
150
|
+
common = sorted(set(qmap[a]) & set(qmap[b]))
|
|
151
|
+
if len(common) < 2:
|
|
152
|
+
continue
|
|
153
|
+
sa = [qmap[a][q] for q in common]
|
|
154
|
+
sb = [qmap[b][q] for q in common]
|
|
155
|
+
clusters = [cluster_of_q[q] for q in common] if cluster_of_q else None
|
|
156
|
+
has_real_clusters = clusters is not None and len(set(clusters)) < len(clusters)
|
|
157
|
+
comp = paired_compare(sa, sb, clusters=clusters if has_real_clusters else None, confidence=confidence)
|
|
158
|
+
pair_keys.append((a, b))
|
|
159
|
+
# Prefer the cluster-robust p-value when clusters are present: it is
|
|
160
|
+
# the honest one when questions are correlated within a cluster.
|
|
161
|
+
raw_p.append(comp.p_value_clustered if comp.p_value_clustered is not None else comp.p_value)
|
|
162
|
+
pairwise.append(PairwiseResult(a, b, comp, p_holm=raw_p[-1])) # placeholder, fixed below
|
|
163
|
+
|
|
164
|
+
adjusted = holm_correction(raw_p) if raw_p else []
|
|
165
|
+
pairwise = [
|
|
166
|
+
PairwiseResult(a, b, pr.comparison, p_holm)
|
|
167
|
+
for (a, b), pr, p_holm in zip(pair_keys, pairwise, adjusted, strict=True)
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
edges = {frozenset((pr.model_a, pr.model_b)) for pr in pairwise if pr.p_holm >= alpha}
|
|
171
|
+
groups = _maximal_cliques(order, edges)
|
|
172
|
+
|
|
173
|
+
return Leaderboard(entries=entries, pairwise=pairwise, groups=groups, alpha=alpha)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _summarize_mean(scores: list[float], confidence: float, use_wilson_below_n: int) -> MeanEstimate:
|
|
177
|
+
n = len(scores)
|
|
178
|
+
if is_binary(scores) and n < use_wilson_below_n:
|
|
179
|
+
successes = int(round(sum(scores)))
|
|
180
|
+
return wilson_ci(successes, n, confidence)
|
|
181
|
+
return mean_ci_clt(scores, confidence)
|
errorbars/plot.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Forest plots for leaderboards.
|
|
2
|
+
|
|
3
|
+
A dependency-free SVG writer always works; ``forest_plot_matplotlib`` is
|
|
4
|
+
used automatically by the CLI when matplotlib is installed (the ``plot``
|
|
5
|
+
extra) and a raster/PDF output is requested.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from errorbars.leaderboard import Leaderboard
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from matplotlib.figure import Figure
|
|
16
|
+
|
|
17
|
+
__all__ = ["forest_plot_svg", "forest_plot_matplotlib"]
|
|
18
|
+
|
|
19
|
+
_FONT = "system-ui, -apple-system, 'Segoe UI', sans-serif"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def forest_plot_svg(
|
|
23
|
+
leaderboard: Leaderboard,
|
|
24
|
+
width: int = 640,
|
|
25
|
+
row_height: int = 44,
|
|
26
|
+
title: str | None = None,
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Render a forest plot (mean + CI per model) as a standalone SVG string."""
|
|
29
|
+
entries = leaderboard.entries
|
|
30
|
+
n = len(entries)
|
|
31
|
+
margin_left, margin_right, margin_top, margin_bottom = 160, 40, 50 if title else 20, 30
|
|
32
|
+
plot_h = n * row_height
|
|
33
|
+
height = margin_top + plot_h + margin_bottom
|
|
34
|
+
plot_w = width - margin_left - margin_right
|
|
35
|
+
|
|
36
|
+
lo = min(e.ci_low for e in entries)
|
|
37
|
+
hi = max(e.ci_high for e in entries)
|
|
38
|
+
pad = (hi - lo) * 0.1 or 0.05
|
|
39
|
+
x_min, x_max = lo - pad, hi + pad
|
|
40
|
+
|
|
41
|
+
def x(v: float) -> float:
|
|
42
|
+
return margin_left + (v - x_min) / (x_max - x_min) * plot_w
|
|
43
|
+
|
|
44
|
+
def y(i: int) -> float:
|
|
45
|
+
return margin_top + i * row_height + row_height / 2
|
|
46
|
+
|
|
47
|
+
parts: list[str] = []
|
|
48
|
+
parts.append(
|
|
49
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
|
|
50
|
+
f'viewBox="0 0 {width} {height}" font-family="{_FONT}">'
|
|
51
|
+
)
|
|
52
|
+
parts.append(f'<rect width="{width}" height="{height}" fill="#ffffff"/>')
|
|
53
|
+
if title:
|
|
54
|
+
parts.append(
|
|
55
|
+
f'<text x="{width / 2}" y="24" text-anchor="middle" font-size="15" '
|
|
56
|
+
f'font-weight="600" fill="#111827">{_escape(title)}</text>'
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# gridlines + x axis ticks
|
|
60
|
+
n_ticks = 5
|
|
61
|
+
for t in range(n_ticks + 1):
|
|
62
|
+
v = x_min + (x_max - x_min) * t / n_ticks
|
|
63
|
+
gx = x(v)
|
|
64
|
+
parts.append(
|
|
65
|
+
f'<line x1="{gx:.1f}" y1="{margin_top}" x2="{gx:.1f}" '
|
|
66
|
+
f'y2="{margin_top + plot_h}" stroke="#e5e7eb" stroke-width="1"/>'
|
|
67
|
+
)
|
|
68
|
+
parts.append(
|
|
69
|
+
f'<text x="{gx:.1f}" y="{margin_top + plot_h + 18}" text-anchor="middle" '
|
|
70
|
+
f'font-size="10" fill="#6b7280">{v:.3f}</text>'
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
groups = leaderboard.groups
|
|
74
|
+
letter_of: dict[str, str] = {}
|
|
75
|
+
for i, group in enumerate(groups):
|
|
76
|
+
letter = chr(ord("a") + i)
|
|
77
|
+
for m in group:
|
|
78
|
+
letter_of[m] = letter_of.get(m, "") + letter
|
|
79
|
+
|
|
80
|
+
for i, e in enumerate(entries):
|
|
81
|
+
cy = y(i)
|
|
82
|
+
parts.append(
|
|
83
|
+
f'<text x="{margin_left - 12}" y="{cy + 4:.1f}" text-anchor="end" '
|
|
84
|
+
f'font-size="12" fill="#111827">{_escape(e.model)}</text>'
|
|
85
|
+
)
|
|
86
|
+
x_lo, x_hi, x_mean = x(e.ci_low), x(e.ci_high), x(e.mean)
|
|
87
|
+
parts.append(
|
|
88
|
+
f'<line x1="{x_lo:.1f}" y1="{cy:.1f}" x2="{x_hi:.1f}" y2="{cy:.1f}" '
|
|
89
|
+
f'stroke="#2563eb" stroke-width="2"/>'
|
|
90
|
+
)
|
|
91
|
+
for edge_x in (x_lo, x_hi):
|
|
92
|
+
parts.append(
|
|
93
|
+
f'<line x1="{edge_x:.1f}" y1="{cy - 5:.1f}" x2="{edge_x:.1f}" '
|
|
94
|
+
f'y2="{cy + 5:.1f}" stroke="#2563eb" stroke-width="2"/>'
|
|
95
|
+
)
|
|
96
|
+
parts.append(f'<circle cx="{x_mean:.1f}" cy="{cy:.1f}" r="4.5" fill="#1d4ed8"/>')
|
|
97
|
+
label = f"{e.mean:.3f}"
|
|
98
|
+
if letter_of.get(e.model):
|
|
99
|
+
label += f" ({letter_of[e.model]})"
|
|
100
|
+
parts.append(
|
|
101
|
+
f'<text x="{x_hi + 10:.1f}" y="{cy + 4:.1f}" font-size="11" '
|
|
102
|
+
f'fill="#374151">{_escape(label)}</text>'
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
parts.append(
|
|
106
|
+
f'<line x1="{margin_left}" y1="{margin_top + plot_h}" x2="{margin_left + plot_w}" '
|
|
107
|
+
f'y2="{margin_top + plot_h}" stroke="#9ca3af" stroke-width="1"/>'
|
|
108
|
+
)
|
|
109
|
+
parts.append("</svg>")
|
|
110
|
+
return "\n".join(parts)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _escape(s: str) -> str:
|
|
114
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def forest_plot_matplotlib(leaderboard: Leaderboard, title: str | None = None) -> Figure:
|
|
118
|
+
"""Render a forest plot with matplotlib. Requires the ``plot`` extra."""
|
|
119
|
+
try:
|
|
120
|
+
import matplotlib.pyplot as plt
|
|
121
|
+
except ImportError as exc: # pragma: no cover
|
|
122
|
+
raise ImportError(
|
|
123
|
+
"forest_plot_matplotlib requires matplotlib (the 'plot' extra): pip install matplotlib"
|
|
124
|
+
) from exc
|
|
125
|
+
|
|
126
|
+
entries = leaderboard.entries
|
|
127
|
+
fig, ax = plt.subplots(figsize=(7, 0.6 * len(entries) + 1.2))
|
|
128
|
+
ys = list(range(len(entries), 0, -1))
|
|
129
|
+
means = [e.mean for e in entries]
|
|
130
|
+
los = [e.mean - e.ci_low for e in entries]
|
|
131
|
+
his = [e.ci_high - e.mean for e in entries]
|
|
132
|
+
ax.errorbar(means, ys, xerr=[los, his], fmt="o", color="#1d4ed8", ecolor="#2563eb", capsize=4)
|
|
133
|
+
ax.set_yticks(ys)
|
|
134
|
+
ax.set_yticklabels([e.model for e in entries])
|
|
135
|
+
ax.set_xlabel("mean score")
|
|
136
|
+
if title:
|
|
137
|
+
ax.set_title(title)
|
|
138
|
+
ax.grid(axis="x", color="#e5e7eb", linewidth=0.8)
|
|
139
|
+
ax.set_axisbelow(True)
|
|
140
|
+
fig.tight_layout()
|
|
141
|
+
return fig
|