judgekit 0.2.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.
- judgekit/__init__.py +21 -0
- judgekit/agreement.py +178 -0
- judgekit/audit.py +448 -0
- judgekit/bias.py +361 -0
- judgekit/bootstrap.py +67 -0
- judgekit/calibration.py +95 -0
- judgekit/cli.py +89 -0
- judgekit/consistency.py +90 -0
- judgekit/demo.py +135 -0
- judgekit/io.py +74 -0
- judgekit/py.typed +0 -0
- judgekit/report.py +176 -0
- judgekit/result.py +66 -0
- judgekit/schema.py +115 -0
- judgekit/synthetic.py +272 -0
- judgekit-0.2.0.dist-info/METADATA +240 -0
- judgekit-0.2.0.dist-info/RECORD +21 -0
- judgekit-0.2.0.dist-info/WHEEL +5 -0
- judgekit-0.2.0.dist-info/entry_points.txt +2 -0
- judgekit-0.2.0.dist-info/licenses/LICENSE +21 -0
- judgekit-0.2.0.dist-info/top_level.txt +1 -0
judgekit/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""judgekit: audit an LLM judge before you trust it."""
|
|
2
|
+
|
|
3
|
+
from judgekit.audit import AuditReport, run_audit
|
|
4
|
+
from judgekit.io import dump_verdicts, load_verdicts
|
|
5
|
+
from judgekit.result import ProbeResult, SkippedProbe
|
|
6
|
+
from judgekit.schema import BinaryVerdict, GradedVerdict, PairwiseVerdict
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AuditReport",
|
|
12
|
+
"BinaryVerdict",
|
|
13
|
+
"GradedVerdict",
|
|
14
|
+
"PairwiseVerdict",
|
|
15
|
+
"ProbeResult",
|
|
16
|
+
"SkippedProbe",
|
|
17
|
+
"__version__",
|
|
18
|
+
"dump_verdicts",
|
|
19
|
+
"load_verdicts",
|
|
20
|
+
"run_audit",
|
|
21
|
+
]
|
judgekit/agreement.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Agreement between a judge and a reference rater.
|
|
2
|
+
|
|
3
|
+
Implemented from scratch on plain float arithmetic; the test suite cross-checks
|
|
4
|
+
every function against an independent reference (scikit-learn, scipy, and the
|
|
5
|
+
krippendorff package). Nothing here calls into BLAS, so results are identical
|
|
6
|
+
across platforms — reductions use numpy's fixed pairwise summation and the
|
|
7
|
+
small confusion/coincidence matrices are accumulated in Python loops.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from numpy.typing import NDArray
|
|
18
|
+
|
|
19
|
+
Weights = Literal["linear", "quadratic"] | None
|
|
20
|
+
FloatArray = Sequence[float] | NDArray[np.float64]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _as_pair(
|
|
24
|
+
a: FloatArray, b: FloatArray
|
|
25
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
26
|
+
x = np.asarray(a, dtype=np.float64)
|
|
27
|
+
y = np.asarray(b, dtype=np.float64)
|
|
28
|
+
if x.shape != y.shape:
|
|
29
|
+
raise ValueError(f"length mismatch: {x.shape} vs {y.shape}")
|
|
30
|
+
if x.size == 0:
|
|
31
|
+
raise ValueError("no ratings")
|
|
32
|
+
return x, y
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def confusion(
|
|
36
|
+
a: FloatArray, b: FloatArray, labels: FloatArray
|
|
37
|
+
) -> NDArray[np.float64]:
|
|
38
|
+
"""Count matrix with rows indexed by ``a`` and columns by ``b``."""
|
|
39
|
+
x, y = _as_pair(a, b)
|
|
40
|
+
index = {label: i for i, label in enumerate(labels)}
|
|
41
|
+
matrix = np.zeros((len(labels), len(labels)), dtype=np.float64)
|
|
42
|
+
for u, v in zip(x.tolist(), y.tolist(), strict=True):
|
|
43
|
+
if u not in index or v not in index:
|
|
44
|
+
raise ValueError(f"rating pair ({u}, {v}) outside labels {list(labels)}")
|
|
45
|
+
matrix[index[u], index[v]] += 1.0
|
|
46
|
+
return matrix
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cohen_kappa(
|
|
50
|
+
a: FloatArray,
|
|
51
|
+
b: FloatArray,
|
|
52
|
+
labels: FloatArray | None = None,
|
|
53
|
+
weights: Weights = None,
|
|
54
|
+
) -> float:
|
|
55
|
+
"""Cohen's kappa, optionally weighted for ordinal scales.
|
|
56
|
+
|
|
57
|
+
Disagreement weights follow scikit-learn: distances are between label
|
|
58
|
+
*positions* in ``labels``, so pass the full scale (for instance
|
|
59
|
+
``[1, 2, 3, 4, 5]``) rather than letting a category that nobody used
|
|
60
|
+
silently shrink the scale. ``quadratic`` is the usual choice for graded
|
|
61
|
+
scores. Returns nan when raters use a single label, where chance-corrected
|
|
62
|
+
agreement is undefined.
|
|
63
|
+
"""
|
|
64
|
+
x, y = _as_pair(a, b)
|
|
65
|
+
if labels is None:
|
|
66
|
+
labels = sorted(set(x.tolist()) | set(y.tolist()))
|
|
67
|
+
counts = confusion(x, y, labels)
|
|
68
|
+
n = counts.sum()
|
|
69
|
+
observed = counts / n
|
|
70
|
+
row = observed.sum(axis=1)
|
|
71
|
+
col = observed.sum(axis=0)
|
|
72
|
+
expected = np.outer(row, col)
|
|
73
|
+
|
|
74
|
+
k = len(labels)
|
|
75
|
+
positions = np.arange(k, dtype=np.float64)
|
|
76
|
+
delta = np.abs(positions[:, None] - positions[None, :])
|
|
77
|
+
if weights is None:
|
|
78
|
+
w = (delta > 0).astype(np.float64)
|
|
79
|
+
elif weights == "linear":
|
|
80
|
+
w = delta
|
|
81
|
+
elif weights == "quadratic":
|
|
82
|
+
w = delta**2
|
|
83
|
+
else:
|
|
84
|
+
raise ValueError(f"unknown weights {weights!r}")
|
|
85
|
+
|
|
86
|
+
expected_disagreement = float((w * expected).sum())
|
|
87
|
+
if expected_disagreement == 0.0:
|
|
88
|
+
return float("nan")
|
|
89
|
+
return 1.0 - float((w * observed).sum()) / expected_disagreement
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def krippendorff_alpha(
|
|
93
|
+
values_by_unit: Sequence[FloatArray],
|
|
94
|
+
level: Literal["nominal", "interval"] = "nominal",
|
|
95
|
+
) -> float:
|
|
96
|
+
"""Krippendorff's alpha over units rated by any number of raters.
|
|
97
|
+
|
|
98
|
+
``values_by_unit`` holds, per unit, the ratings that were actually given;
|
|
99
|
+
missing ratings are simply absent, which is the point of alpha. Units with
|
|
100
|
+
fewer than two ratings carry no agreement information and are skipped.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def distance(u: float, v: float) -> float:
|
|
104
|
+
if level == "interval":
|
|
105
|
+
return (u - v) ** 2
|
|
106
|
+
if level == "nominal":
|
|
107
|
+
return 0.0 if u == v else 1.0
|
|
108
|
+
raise ValueError(f"unknown level {level!r}")
|
|
109
|
+
|
|
110
|
+
units = [[float(v) for v in unit] for unit in values_by_unit if len(unit) >= 2]
|
|
111
|
+
if not units:
|
|
112
|
+
raise ValueError("no unit has two or more ratings")
|
|
113
|
+
n_pairable = float(sum(len(unit) for unit in units))
|
|
114
|
+
|
|
115
|
+
observed = 0.0
|
|
116
|
+
for unit in units:
|
|
117
|
+
m = len(unit)
|
|
118
|
+
pair_sum = 0.0
|
|
119
|
+
for i in range(m):
|
|
120
|
+
for j in range(m):
|
|
121
|
+
if i != j:
|
|
122
|
+
pair_sum += distance(unit[i], unit[j])
|
|
123
|
+
observed += pair_sum / (m - 1)
|
|
124
|
+
observed /= n_pairable
|
|
125
|
+
|
|
126
|
+
pooled = Counter(value for unit in units for value in unit)
|
|
127
|
+
expected = 0.0
|
|
128
|
+
for u, n_u in pooled.items():
|
|
129
|
+
for v, n_v in pooled.items():
|
|
130
|
+
if u != v:
|
|
131
|
+
expected += n_u * n_v * distance(u, v)
|
|
132
|
+
expected /= n_pairable * (n_pairable - 1.0)
|
|
133
|
+
|
|
134
|
+
if expected == 0.0:
|
|
135
|
+
return float("nan")
|
|
136
|
+
return 1.0 - observed / expected
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def rank_average(values: FloatArray) -> NDArray[np.float64]:
|
|
140
|
+
"""Ranks starting at 1, ties sharing the average rank."""
|
|
141
|
+
x = np.asarray(values, dtype=np.float64)
|
|
142
|
+
order = np.argsort(x, kind="stable")
|
|
143
|
+
ranks = np.empty(x.size, dtype=np.float64)
|
|
144
|
+
sorted_x = x[order]
|
|
145
|
+
i = 0
|
|
146
|
+
while i < x.size:
|
|
147
|
+
j = i
|
|
148
|
+
while j + 1 < x.size and sorted_x[j + 1] == sorted_x[i]:
|
|
149
|
+
j += 1
|
|
150
|
+
ranks[order[i : j + 1]] = (i + j) / 2.0 + 1.0
|
|
151
|
+
i = j + 1
|
|
152
|
+
return ranks
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def pearson(a: FloatArray, b: FloatArray) -> float:
|
|
156
|
+
x, y = _as_pair(a, b)
|
|
157
|
+
xc = x - x.mean()
|
|
158
|
+
yc = y - y.mean()
|
|
159
|
+
denominator = float(np.sqrt((xc**2).sum() * (yc**2).sum()))
|
|
160
|
+
if denominator == 0.0:
|
|
161
|
+
return float("nan")
|
|
162
|
+
return float((xc * yc).sum()) / denominator
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def spearman(a: FloatArray, b: FloatArray) -> float:
|
|
166
|
+
"""Spearman rank correlation with average ranks for ties."""
|
|
167
|
+
return pearson(rank_average(a), rank_average(b))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def exact_agreement(a: FloatArray, b: FloatArray) -> float:
|
|
171
|
+
x, y = _as_pair(a, b)
|
|
172
|
+
return float((x == y).mean())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def within_one(a: FloatArray, b: FloatArray) -> float:
|
|
176
|
+
"""Share of ratings at most one scale point apart, for integer scales."""
|
|
177
|
+
x, y = _as_pair(a, b)
|
|
178
|
+
return float((np.abs(x - y) <= 1.0 + 1e-12).mean())
|
judgekit/audit.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Run every applicable check on a judge's verdicts and collect the results.
|
|
2
|
+
|
|
3
|
+
The audit takes two verdict files — the judge's and the reference (usually
|
|
4
|
+
human) — and produces one ``AuditReport``: agreement, calibration, bias probes
|
|
5
|
+
and consistency, each as a ``ProbeResult`` with a bootstrap interval. Which
|
|
6
|
+
checks run is decided by what the data contains; nothing is required beyond
|
|
7
|
+
matching ``item_id`` values.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from judgekit import bias, consistency
|
|
16
|
+
from judgekit.agreement import cohen_kappa, exact_agreement, spearman, within_one
|
|
17
|
+
from judgekit.bootstrap import bootstrap_ci
|
|
18
|
+
from judgekit.calibration import brier, ece_equal_mass, murphy_decomposition, reliability_curve
|
|
19
|
+
from judgekit.io import align_on_items, by_kind
|
|
20
|
+
from judgekit.result import ProbeResult, SkippedProbe
|
|
21
|
+
from judgekit.schema import BinaryVerdict, GradedVerdict, PairwiseVerdict, Verdict
|
|
22
|
+
|
|
23
|
+
# The finite-sample floor of equal-mass ECE at the demo's scale is a few
|
|
24
|
+
# hundredths even for a perfectly calibrated judge, so the innocent band
|
|
25
|
+
# stops at 0.05 rather than zero.
|
|
26
|
+
INNOCENT_ECE = (0.0, 0.05)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class AuditReport:
|
|
31
|
+
judge_id: str
|
|
32
|
+
n_graded: int
|
|
33
|
+
n_binary: int
|
|
34
|
+
n_pairwise: int
|
|
35
|
+
agreement: list[ProbeResult] = field(default_factory=list)
|
|
36
|
+
calibration: list[ProbeResult] = field(default_factory=list)
|
|
37
|
+
probes: list[ProbeResult] = field(default_factory=list)
|
|
38
|
+
skipped: list[SkippedProbe] = field(default_factory=list)
|
|
39
|
+
# Small data slices the figures are drawn from.
|
|
40
|
+
reliability: list[tuple[float, float, int]] | None = None
|
|
41
|
+
graded_scores: tuple[list[float], list[float]] | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def results(self) -> list[ProbeResult]:
|
|
45
|
+
return [*self.agreement, *self.calibration, *self.probes]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def flags(self) -> list[ProbeResult]:
|
|
49
|
+
return [result for result in self.results if result.triggered is True]
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, object]:
|
|
52
|
+
"""JSON-safe view of the whole audit, for pipelines that gate on it."""
|
|
53
|
+
return {
|
|
54
|
+
"judge_id": self.judge_id,
|
|
55
|
+
"n_graded": self.n_graded,
|
|
56
|
+
"n_binary": self.n_binary,
|
|
57
|
+
"n_pairwise": self.n_pairwise,
|
|
58
|
+
"agreement": [result.to_dict() for result in self.agreement],
|
|
59
|
+
"calibration": [result.to_dict() for result in self.calibration],
|
|
60
|
+
"probes": [result.to_dict() for result in self.probes],
|
|
61
|
+
"flags": [flag.name for flag in self.flags],
|
|
62
|
+
"skipped": [{"name": skip.name, "needs": skip.needs} for skip in self.skipped],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _grouped_scores(
|
|
67
|
+
pairs: Sequence[tuple[Verdict, Verdict]],
|
|
68
|
+
values: Sequence[tuple[float, float]],
|
|
69
|
+
) -> list[list[tuple[float, float]]]:
|
|
70
|
+
units: dict[str, list[tuple[float, float]]] = {}
|
|
71
|
+
for (judge, _), row in zip(pairs, values, strict=True):
|
|
72
|
+
units.setdefault(judge.item_id, []).append(row)
|
|
73
|
+
return list(units.values())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _pooled(units: list[list[tuple[float, float]]]) -> tuple[list[float], list[float]]:
|
|
77
|
+
flat = [row for unit in units for row in unit]
|
|
78
|
+
return [row[0] for row in flat], [row[1] for row in flat]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _agreement_graded(
|
|
82
|
+
pairs: Sequence[tuple[GradedVerdict, GradedVerdict]], seed: int
|
|
83
|
+
) -> list[ProbeResult]:
|
|
84
|
+
scale_min = pairs[0][0].scale_min
|
|
85
|
+
scale_max = pairs[0][0].scale_max
|
|
86
|
+
values = [(judge.score, human.score) for judge, human in pairs]
|
|
87
|
+
units = _grouped_scores(pairs, values)
|
|
88
|
+
results = []
|
|
89
|
+
|
|
90
|
+
integral = all(
|
|
91
|
+
float(judge).is_integer() and float(human).is_integer() for judge, human in values
|
|
92
|
+
)
|
|
93
|
+
if integral and scale_min.is_integer() and scale_max.is_integer():
|
|
94
|
+
labels = [float(v) for v in range(int(scale_min), int(scale_max) + 1)]
|
|
95
|
+
|
|
96
|
+
def kappa(sample: list[list[tuple[float, float]]]) -> float:
|
|
97
|
+
judge_scores, human_scores = _pooled(sample)
|
|
98
|
+
return cohen_kappa(judge_scores, human_scores, labels=labels, weights="quadratic")
|
|
99
|
+
|
|
100
|
+
value = kappa(units)
|
|
101
|
+
results.append(
|
|
102
|
+
ProbeResult(
|
|
103
|
+
name="quadratic-weighted kappa",
|
|
104
|
+
value=value,
|
|
105
|
+
ci=bootstrap_ci(kappa, units, seed=seed),
|
|
106
|
+
innocent=None,
|
|
107
|
+
n=len(values),
|
|
108
|
+
detail=(
|
|
109
|
+
f"chance-corrected ordinal agreement with the reference "
|
|
110
|
+
f"on {len(values)} verdicts"
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def rho(sample: list[list[tuple[float, float]]]) -> float:
|
|
116
|
+
judge_scores, human_scores = _pooled(sample)
|
|
117
|
+
return spearman(judge_scores, human_scores)
|
|
118
|
+
|
|
119
|
+
results.append(
|
|
120
|
+
ProbeResult(
|
|
121
|
+
name="spearman rho",
|
|
122
|
+
value=rho(units),
|
|
123
|
+
ci=bootstrap_ci(rho, units, seed=seed),
|
|
124
|
+
innocent=None,
|
|
125
|
+
n=len(values),
|
|
126
|
+
detail="rank correlation with the reference scores",
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if integral:
|
|
131
|
+
|
|
132
|
+
def exact(sample: list[list[tuple[float, float]]]) -> float:
|
|
133
|
+
judge_scores, human_scores = _pooled(sample)
|
|
134
|
+
return exact_agreement(judge_scores, human_scores)
|
|
135
|
+
|
|
136
|
+
def close(sample: list[list[tuple[float, float]]]) -> float:
|
|
137
|
+
judge_scores, human_scores = _pooled(sample)
|
|
138
|
+
return within_one(judge_scores, human_scores)
|
|
139
|
+
|
|
140
|
+
results.append(
|
|
141
|
+
ProbeResult(
|
|
142
|
+
name="exact agreement",
|
|
143
|
+
value=exact(units),
|
|
144
|
+
ci=bootstrap_ci(exact, units, seed=seed),
|
|
145
|
+
innocent=None,
|
|
146
|
+
n=len(values),
|
|
147
|
+
detail="share of verdicts equal to the reference score",
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
results.append(
|
|
151
|
+
ProbeResult(
|
|
152
|
+
name="within one point",
|
|
153
|
+
value=close(units),
|
|
154
|
+
ci=bootstrap_ci(close, units, seed=seed),
|
|
155
|
+
innocent=None,
|
|
156
|
+
n=len(values),
|
|
157
|
+
detail="share of verdicts at most one scale point from the reference",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return results
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _agreement_binary(
|
|
164
|
+
pairs: Sequence[tuple[BinaryVerdict, BinaryVerdict]], seed: int
|
|
165
|
+
) -> list[ProbeResult]:
|
|
166
|
+
values = [(1.0 if judge.label else 0.0, 1.0 if human.label else 0.0) for judge, human in pairs]
|
|
167
|
+
units = _grouped_scores(pairs, values)
|
|
168
|
+
|
|
169
|
+
def accuracy(sample: list[list[tuple[float, float]]]) -> float:
|
|
170
|
+
judge_labels, human_labels = _pooled(sample)
|
|
171
|
+
return exact_agreement(judge_labels, human_labels)
|
|
172
|
+
|
|
173
|
+
def kappa(sample: list[list[tuple[float, float]]]) -> float:
|
|
174
|
+
judge_labels, human_labels = _pooled(sample)
|
|
175
|
+
return cohen_kappa(judge_labels, human_labels, labels=[0.0, 1.0])
|
|
176
|
+
|
|
177
|
+
return [
|
|
178
|
+
ProbeResult(
|
|
179
|
+
name="label accuracy",
|
|
180
|
+
value=accuracy(units),
|
|
181
|
+
ci=bootstrap_ci(accuracy, units, seed=seed),
|
|
182
|
+
innocent=None,
|
|
183
|
+
n=len(values),
|
|
184
|
+
detail=f"share of pass/fail labels matching the reference on {len(values)} verdicts",
|
|
185
|
+
),
|
|
186
|
+
ProbeResult(
|
|
187
|
+
name="cohen kappa",
|
|
188
|
+
value=kappa(units),
|
|
189
|
+
ci=bootstrap_ci(kappa, units, seed=seed),
|
|
190
|
+
innocent=None,
|
|
191
|
+
n=len(values),
|
|
192
|
+
detail="chance-corrected label agreement",
|
|
193
|
+
),
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _agreement_pairwise(
|
|
198
|
+
pairs: Sequence[tuple[PairwiseVerdict, PairwiseVerdict]], seed: int
|
|
199
|
+
) -> list[ProbeResult]:
|
|
200
|
+
categories = {"a": 0.0, "b": 1.0, "tie": 2.0}
|
|
201
|
+
values = [(categories[judge.choice], categories[human.choice]) for judge, human in pairs]
|
|
202
|
+
units = _grouped_scores(pairs, values)
|
|
203
|
+
|
|
204
|
+
def match(sample: list[list[tuple[float, float]]]) -> float:
|
|
205
|
+
judge_choices, human_choices = _pooled(sample)
|
|
206
|
+
return exact_agreement(judge_choices, human_choices)
|
|
207
|
+
|
|
208
|
+
return [
|
|
209
|
+
ProbeResult(
|
|
210
|
+
name="choice agreement",
|
|
211
|
+
value=match(units),
|
|
212
|
+
ci=bootstrap_ci(match, units, seed=seed),
|
|
213
|
+
innocent=None,
|
|
214
|
+
n=len(values),
|
|
215
|
+
detail=(
|
|
216
|
+
f"share of presentations whose canonical choice matches the reference "
|
|
217
|
+
f"on {len(values)} verdicts"
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _calibration(
|
|
224
|
+
pairs: Sequence[tuple[BinaryVerdict, BinaryVerdict]], seed: int
|
|
225
|
+
) -> tuple[list[ProbeResult], list[tuple[float, float, int]] | None]:
|
|
226
|
+
confident = [
|
|
227
|
+
(judge.p_positive, 1.0 if human.label else 0.0)
|
|
228
|
+
for judge, human in pairs
|
|
229
|
+
if judge.p_positive is not None
|
|
230
|
+
]
|
|
231
|
+
if len(confident) < 20:
|
|
232
|
+
return [], None
|
|
233
|
+
values = [(p, y) for p, y in confident if p is not None]
|
|
234
|
+
units = _grouped_scores(
|
|
235
|
+
[pair for pair in pairs if pair[0].p_positive is not None], values
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def ece(sample: list[list[tuple[float, float]]]) -> float:
|
|
239
|
+
probabilities, outcomes = _pooled(sample)
|
|
240
|
+
return ece_equal_mass(probabilities, outcomes)
|
|
241
|
+
|
|
242
|
+
probabilities, outcomes = _pooled(units)
|
|
243
|
+
reliability_probe = ProbeResult(
|
|
244
|
+
name="expected calibration error",
|
|
245
|
+
value=ece(units),
|
|
246
|
+
ci=bootstrap_ci(ece, units, seed=seed),
|
|
247
|
+
innocent=INNOCENT_ECE,
|
|
248
|
+
n=len(values),
|
|
249
|
+
detail="mean gap between claimed confidence and observed frequency, equal-mass bins",
|
|
250
|
+
)
|
|
251
|
+
rel, res, unc = murphy_decomposition(probabilities, outcomes)
|
|
252
|
+
decomposition = ProbeResult(
|
|
253
|
+
name="brier score",
|
|
254
|
+
value=brier(probabilities, outcomes),
|
|
255
|
+
ci=(float("nan"), float("nan")),
|
|
256
|
+
innocent=None,
|
|
257
|
+
n=len(values),
|
|
258
|
+
detail=(
|
|
259
|
+
f"decomposes exactly into reliability {rel:.3f} - resolution {res:.3f} "
|
|
260
|
+
f"+ uncertainty {unc:.3f}"
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
return [reliability_probe, decomposition], reliability_curve(probabilities, outcomes)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def run_audit(
|
|
267
|
+
judge: Sequence[Verdict],
|
|
268
|
+
human: Sequence[Verdict],
|
|
269
|
+
*,
|
|
270
|
+
judge_model: str | None = None,
|
|
271
|
+
seed: int = 0,
|
|
272
|
+
) -> AuditReport:
|
|
273
|
+
"""Audit one judge against one reference; see the module docstring."""
|
|
274
|
+
judge_ids = {verdict.judge_id for verdict in judge}
|
|
275
|
+
if len(judge_ids) > 1:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"one audit reads one judge, got judge_ids {sorted(judge_ids)}; "
|
|
278
|
+
"split the verdicts and audit each judge separately"
|
|
279
|
+
)
|
|
280
|
+
judge_graded, judge_binary, judge_pairwise = by_kind(judge)
|
|
281
|
+
human_graded, human_binary, human_pairwise = by_kind(human)
|
|
282
|
+
judge_id = judge[0].judge_id if judge else "judge"
|
|
283
|
+
|
|
284
|
+
agreement: list[ProbeResult] = []
|
|
285
|
+
calibration: list[ProbeResult] = []
|
|
286
|
+
probes: list[ProbeResult] = []
|
|
287
|
+
skipped: list[SkippedProbe] = []
|
|
288
|
+
reliability = None
|
|
289
|
+
graded_scores = None
|
|
290
|
+
|
|
291
|
+
graded_pairs = align_on_items(judge_graded, human_graded) if human_graded else []
|
|
292
|
+
if judge_graded and not graded_pairs:
|
|
293
|
+
skipped.append(
|
|
294
|
+
SkippedProbe(
|
|
295
|
+
"graded agreement and bias probes",
|
|
296
|
+
"reference graded verdicts sharing item_id values with the judge's",
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
if graded_pairs:
|
|
300
|
+
scales = {(v.scale_min, v.scale_max) for v, _ in graded_pairs} | {
|
|
301
|
+
(v.scale_min, v.scale_max) for _, v in graded_pairs
|
|
302
|
+
}
|
|
303
|
+
if len(scales) > 1:
|
|
304
|
+
raise ValueError(f"mixed score scales in one audit: {sorted(scales)}")
|
|
305
|
+
agreement.extend(_agreement_graded(graded_pairs, seed))
|
|
306
|
+
graded_scores = (
|
|
307
|
+
[judge_verdict.score for judge_verdict, _ in graded_pairs],
|
|
308
|
+
[human_verdict.score for _, human_verdict in graded_pairs],
|
|
309
|
+
)
|
|
310
|
+
verbosity_probe = bias.verbosity_graded(graded_pairs, seed=seed)
|
|
311
|
+
if verbosity_probe is not None:
|
|
312
|
+
probes.append(verbosity_probe)
|
|
313
|
+
else:
|
|
314
|
+
skipped.append(
|
|
315
|
+
SkippedProbe("verbosity bias", "candidate_len on at least 3 graded verdicts")
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if judge_model is None:
|
|
319
|
+
skipped.append(
|
|
320
|
+
SkippedProbe(
|
|
321
|
+
"self-preference",
|
|
322
|
+
"the judge's model name (judge_model / --judge-model) "
|
|
323
|
+
"plus candidate_model on graded verdicts",
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
else:
|
|
327
|
+
self_probe = bias.self_preference(graded_pairs, judge_model, seed=seed)
|
|
328
|
+
if self_probe is not None:
|
|
329
|
+
probes.append(self_probe)
|
|
330
|
+
else:
|
|
331
|
+
skipped.append(
|
|
332
|
+
SkippedProbe(
|
|
333
|
+
"self-preference",
|
|
334
|
+
"candidate_model on graded verdicts, with both own-model "
|
|
335
|
+
"and other-model candidates present",
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
spread_probe = bias.central_tendency(graded_pairs, seed=seed)
|
|
340
|
+
if spread_probe is not None:
|
|
341
|
+
probes.append(spread_probe)
|
|
342
|
+
else:
|
|
343
|
+
skipped.append(
|
|
344
|
+
SkippedProbe(
|
|
345
|
+
"central tendency",
|
|
346
|
+
"at least 3 aligned graded verdicts with varying reference scores",
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
binary_pairs = align_on_items(judge_binary, human_binary) if human_binary else []
|
|
351
|
+
if judge_binary and not binary_pairs:
|
|
352
|
+
skipped.append(
|
|
353
|
+
SkippedProbe(
|
|
354
|
+
"binary agreement and calibration",
|
|
355
|
+
"reference binary verdicts sharing item_id values with the judge's",
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
if binary_pairs:
|
|
359
|
+
agreement.extend(_agreement_binary(binary_pairs, seed))
|
|
360
|
+
calibration, reliability = _calibration(binary_pairs, seed)
|
|
361
|
+
if not calibration:
|
|
362
|
+
confident = sum(1 for j, _ in binary_pairs if j.p_positive is not None)
|
|
363
|
+
skipped.append(
|
|
364
|
+
SkippedProbe(
|
|
365
|
+
"calibration (ECE, Brier)",
|
|
366
|
+
f"confidence on at least 20 aligned binary verdicts (found {confident})",
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if judge_pairwise:
|
|
371
|
+
pairwise_pairs = (
|
|
372
|
+
align_on_items(judge_pairwise, human_pairwise) if human_pairwise else []
|
|
373
|
+
)
|
|
374
|
+
if not pairwise_pairs:
|
|
375
|
+
skipped.append(
|
|
376
|
+
SkippedProbe(
|
|
377
|
+
"choice agreement and verbosity bias (pairwise)",
|
|
378
|
+
"reference pairwise verdicts sharing item_id values with the judge's",
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
else:
|
|
382
|
+
agreement.extend(_agreement_pairwise(pairwise_pairs, seed))
|
|
383
|
+
verbosity = bias.verbosity_pairwise(pairwise_pairs, seed=seed)
|
|
384
|
+
if verbosity is not None:
|
|
385
|
+
probes.append(verbosity)
|
|
386
|
+
else:
|
|
387
|
+
skipped.append(
|
|
388
|
+
SkippedProbe(
|
|
389
|
+
"verbosity bias (pairwise)",
|
|
390
|
+
"a_len and b_len on pairwise verdicts, with unequal lengths "
|
|
391
|
+
"and decisive verdicts on both sides",
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
for probe, skip in (
|
|
395
|
+
(
|
|
396
|
+
bias.position_preference(judge_pairwise, seed=seed),
|
|
397
|
+
SkippedProbe(
|
|
398
|
+
"position preference", "at least one decisive (non-tie) pairwise verdict"
|
|
399
|
+
),
|
|
400
|
+
),
|
|
401
|
+
(
|
|
402
|
+
bias.swap_flip_rate(judge_pairwise, seed=seed),
|
|
403
|
+
SkippedProbe(
|
|
404
|
+
"swap flip rate",
|
|
405
|
+
"each pair judged in both presentation orders (swapped false and true)",
|
|
406
|
+
),
|
|
407
|
+
),
|
|
408
|
+
(
|
|
409
|
+
bias.identical_pair_decisiveness(judge_pairwise, seed=seed),
|
|
410
|
+
SkippedProbe(
|
|
411
|
+
"identical-pair decisiveness",
|
|
412
|
+
'pairs of identical candidates marked meta {"identical": true}',
|
|
413
|
+
),
|
|
414
|
+
),
|
|
415
|
+
):
|
|
416
|
+
if probe is not None:
|
|
417
|
+
probes.append(probe)
|
|
418
|
+
else:
|
|
419
|
+
skipped.append(skip)
|
|
420
|
+
|
|
421
|
+
stability_ran = False
|
|
422
|
+
for kind_verdicts in (judge_graded, judge_binary, judge_pairwise):
|
|
423
|
+
if kind_verdicts:
|
|
424
|
+
stability = consistency.resample_consistency(kind_verdicts, seed=seed)
|
|
425
|
+
if stability is not None:
|
|
426
|
+
probes.append(stability)
|
|
427
|
+
stability_ran = True
|
|
428
|
+
if judge and not stability_ran:
|
|
429
|
+
skipped.append(
|
|
430
|
+
SkippedProbe(
|
|
431
|
+
"re-judgment unanimity",
|
|
432
|
+
"the same item judged more than once (sample_index 0..k-1, "
|
|
433
|
+
"per presentation order for pairwise)",
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
return AuditReport(
|
|
438
|
+
judge_id=judge_id,
|
|
439
|
+
n_graded=len(judge_graded),
|
|
440
|
+
n_binary=len(judge_binary),
|
|
441
|
+
n_pairwise=len(judge_pairwise),
|
|
442
|
+
agreement=agreement,
|
|
443
|
+
calibration=calibration,
|
|
444
|
+
probes=probes,
|
|
445
|
+
skipped=skipped,
|
|
446
|
+
reliability=reliability,
|
|
447
|
+
graded_scores=graded_scores,
|
|
448
|
+
)
|