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/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)
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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