proofstep-core 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.
- proofstep_core/__init__.py +59 -0
- proofstep_core/aggregate.py +140 -0
- proofstep_core/calibration.py +849 -0
- proofstep_core/calibration_runner.py +471 -0
- proofstep_core/compare.py +205 -0
- proofstep_core/dataset.py +174 -0
- proofstep_core/evaluators/__init__.py +47 -0
- proofstep_core/evaluators/deterministic.py +404 -0
- proofstep_core/evaluators/judge.py +335 -0
- proofstep_core/evaluators/operational.py +127 -0
- proofstep_core/evaluators/statistical.py +434 -0
- proofstep_core/gates.py +545 -0
- proofstep_core/paths.py +98 -0
- proofstep_core/py.typed +0 -0
- proofstep_core/redaction.py +263 -0
- proofstep_core/runner.py +482 -0
- proofstep_core/sampling.py +200 -0
- proofstep_core/significance.py +477 -0
- proofstep_core/stats.py +142 -0
- proofstep_core/suite.py +277 -0
- proofstep_core/testing.py +118 -0
- proofstep_core/types.py +156 -0
- proofstep_core/versioning.py +63 -0
- proofstep_core-0.1.0.dist-info/METADATA +39 -0
- proofstep_core-0.1.0.dist-info/RECORD +26 -0
- proofstep_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
"""Judge calibration — deciding whether a judge's numbers are measurements.
|
|
2
|
+
|
|
3
|
+
An LLM judge is a measuring instrument. An uncalibrated instrument produces numbers,
|
|
4
|
+
not measurements, and gating a merge on one means blocking engineers on a figure nobody
|
|
5
|
+
has checked. This module is the check.
|
|
6
|
+
|
|
7
|
+
Everything here is pure: labelled examples in, a report out. No provider calls, no
|
|
8
|
+
database, no clock. The judge is *run* elsewhere (`calibration_runner.py`); the maths
|
|
9
|
+
lives here so it can be verified against hand-computed values.
|
|
10
|
+
|
|
11
|
+
Four things this module insists on, each because the obvious alternative is wrong:
|
|
12
|
+
|
|
13
|
+
1. **Agreement alone is not evidence.** On a task where 90 % of examples are one class,
|
|
14
|
+
a judge that always answers that class agrees 90 % of the time and has measured
|
|
15
|
+
nothing. Cohen's κ corrects for chance agreement, so it is the headline number.
|
|
16
|
+
|
|
17
|
+
2. **The human ceiling bounds the judge.** If two humans agree at κ = 0.6, a judge at
|
|
18
|
+
κ = 0.6 is at the ceiling and further tuning fits noise. A judge is never held to a
|
|
19
|
+
standard the task itself does not support.
|
|
20
|
+
|
|
21
|
+
3. **False passes and false fails are not interchangeable.** A judge that passes what a
|
|
22
|
+
human failed lets real defects merge. A judge that fails what a human passed erodes
|
|
23
|
+
trust until people bypass the gate. They are reported separately and gated
|
|
24
|
+
separately, because one is a safety problem and the other is an adoption problem.
|
|
25
|
+
|
|
26
|
+
4. **A small calibration set cannot certify anything.** κ on 30 examples has a
|
|
27
|
+
confidence interval roughly ±0.25 wide. Reporting `κ = 0.81 ≥ 0.80, passed` from
|
|
28
|
+
such a set is false precision, so sample-size floors are part of the requirement
|
|
29
|
+
rather than advice.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import math
|
|
35
|
+
import random
|
|
36
|
+
from collections.abc import Mapping, Sequence
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from decimal import Decimal
|
|
39
|
+
from typing import Literal
|
|
40
|
+
|
|
41
|
+
from proofstep_core.stats import mean, percentile, stddev, wilson_ci
|
|
42
|
+
|
|
43
|
+
# Defaults from docs/EVALUATION_ENGINE.md §5: ≥100 examples, ≥50 per class.
|
|
44
|
+
MIN_EXAMPLES = 100
|
|
45
|
+
MIN_PER_CLASS = 50
|
|
46
|
+
# Below this the doubly-labelled subset cannot estimate a human ceiling worth reporting.
|
|
47
|
+
MIN_CEILING_EXAMPLES = 20
|
|
48
|
+
|
|
49
|
+
KappaKind = Literal["unweighted", "linear", "quadratic"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --------------------------------------------------------------------------- inputs
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class LabelledExample:
|
|
57
|
+
"""One human-labelled calibration example.
|
|
58
|
+
|
|
59
|
+
Labels are strings even for a 1-5 rubric. One representation covers binary,
|
|
60
|
+
classification, and ordinal judges, and `ordinal_order` is what tells the maths
|
|
61
|
+
that "2" is nearer "3" than to "5" — without it, a judge that answers 4 where a
|
|
62
|
+
human said 5 is penalised exactly as much as one that answers 1.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
example_id: str
|
|
66
|
+
human_label: str
|
|
67
|
+
#: A second independent annotator, on the subset that has one. This is the only
|
|
68
|
+
#: input from which a human ceiling can be computed; without it the report says so
|
|
69
|
+
#: rather than inventing one.
|
|
70
|
+
second_human_label: str | None = None
|
|
71
|
+
#: Adjudicated label where two annotators disagreed and a human resolved it.
|
|
72
|
+
adjudicated_label: str | None = None
|
|
73
|
+
#: Length of the evaluated output, in characters. Enables verbosity-bias detection.
|
|
74
|
+
output_length: int | None = None
|
|
75
|
+
metadata: Mapping[str, str] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def reference_label(self) -> str:
|
|
79
|
+
"""The label the judge is scored against.
|
|
80
|
+
|
|
81
|
+
Adjudication wins when present: where two annotators disagreed and a human
|
|
82
|
+
resolved it, the resolution is the ground truth, and scoring the judge against
|
|
83
|
+
the unadjudicated first pass would penalise it for the annotator's error.
|
|
84
|
+
"""
|
|
85
|
+
return self.adjudicated_label or self.human_label
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class JudgeVerdict:
|
|
90
|
+
"""What the judge returned for one calibration example."""
|
|
91
|
+
|
|
92
|
+
example_id: str
|
|
93
|
+
label: str | None = None
|
|
94
|
+
errored: bool = False
|
|
95
|
+
error: str | None = None
|
|
96
|
+
cost: Decimal = Decimal(0)
|
|
97
|
+
latency_ms: int = 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class PairwiseProbe:
|
|
102
|
+
"""One pairwise comparison run in both orders, for position-bias detection.
|
|
103
|
+
|
|
104
|
+
`winner_ab` is the item the judge chose when A was presented first; `winner_ba` is
|
|
105
|
+
its choice when the same two items were presented in the opposite order. A judge
|
|
106
|
+
with no position bias returns the same *item* both times.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
example_id: str
|
|
110
|
+
item_a: str
|
|
111
|
+
item_b: str
|
|
112
|
+
winner_ab: str | None
|
|
113
|
+
winner_ba: str | None
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def consistent(self) -> bool:
|
|
117
|
+
"""True when swapping the order did not change the judge's choice."""
|
|
118
|
+
return (
|
|
119
|
+
self.winner_ab is not None
|
|
120
|
+
and self.winner_ba is not None
|
|
121
|
+
and self.winner_ab == self.winner_ba
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def chose_first_both_times(self) -> bool:
|
|
126
|
+
"""True when the judge picked whichever item came first, whatever it was."""
|
|
127
|
+
return self.winner_ab == self.item_a and self.winner_ba == self.item_b
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------- outputs
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class ConfusionMatrix:
|
|
135
|
+
"""Counts indexed `[human_label][judge_label]`."""
|
|
136
|
+
|
|
137
|
+
labels: tuple[str, ...]
|
|
138
|
+
counts: Mapping[tuple[str, str], int]
|
|
139
|
+
|
|
140
|
+
def get(self, human: str, judge: str) -> int:
|
|
141
|
+
return self.counts.get((human, judge), 0)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def total(self) -> int:
|
|
145
|
+
return sum(self.counts.values())
|
|
146
|
+
|
|
147
|
+
def row_total(self, human: str) -> int:
|
|
148
|
+
return sum(self.get(human, judge) for judge in self.labels)
|
|
149
|
+
|
|
150
|
+
def column_total(self, judge: str) -> int:
|
|
151
|
+
return sum(self.get(human, judge) for human in self.labels)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class ClassBreakdown:
|
|
156
|
+
"""Per-class behaviour, so a rare class cannot hide inside an average."""
|
|
157
|
+
|
|
158
|
+
label: str
|
|
159
|
+
support: int
|
|
160
|
+
recall: float
|
|
161
|
+
precision: float
|
|
162
|
+
#: Which label the judge most often substituted for this one, and how often.
|
|
163
|
+
top_confusion: tuple[str, int] | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True, slots=True)
|
|
167
|
+
class CalibrationReport:
|
|
168
|
+
"""Everything measured about one judge version against one labelled set."""
|
|
169
|
+
|
|
170
|
+
n_examples: int
|
|
171
|
+
n_errored: int
|
|
172
|
+
agreement: float
|
|
173
|
+
agreement_ci: tuple[float, float]
|
|
174
|
+
kappa: float | None
|
|
175
|
+
kappa_ci: tuple[float, float] | None
|
|
176
|
+
kappa_kind: KappaKind
|
|
177
|
+
#: Why κ could not be computed, when it could not. Never silently zero.
|
|
178
|
+
kappa_undefined_reason: str | None
|
|
179
|
+
false_pass_rate: float | None
|
|
180
|
+
false_fail_rate: float | None
|
|
181
|
+
confusion: ConfusionMatrix
|
|
182
|
+
per_class: tuple[ClassBreakdown, ...]
|
|
183
|
+
|
|
184
|
+
#: κ between two human annotators on the doubly-labelled subset.
|
|
185
|
+
human_kappa: float | None = None
|
|
186
|
+
#: The judge's κ **restricted to that same subset**, which is the only comparable
|
|
187
|
+
#: number — see `_ceiling`.
|
|
188
|
+
judge_kappa_on_ceiling_subset: float | None = None
|
|
189
|
+
n_ceiling_examples: int = 0
|
|
190
|
+
|
|
191
|
+
mean_cost: Decimal = Decimal(0)
|
|
192
|
+
total_cost: Decimal = Decimal(0)
|
|
193
|
+
p50_latency_ms: float = 0.0
|
|
194
|
+
p95_latency_ms: float = 0.0
|
|
195
|
+
|
|
196
|
+
#: Judge mean minus human mean on an ordinal scale. Positive means the judge is
|
|
197
|
+
#: more generous than the humans.
|
|
198
|
+
leniency: float | None = None
|
|
199
|
+
#: Judge spread divided by human spread. Well below 1 means the judge compresses
|
|
200
|
+
#: the scale, so real regressions fall below its resolution.
|
|
201
|
+
scale_compression: float | None = None
|
|
202
|
+
#: Correlation between output length and the judge's signed error. Positive means
|
|
203
|
+
#: longer outputs are scored more generously than the humans scored them.
|
|
204
|
+
verbosity_bias: float | None = None
|
|
205
|
+
|
|
206
|
+
position_bias: PositionBiasReport | None = None
|
|
207
|
+
|
|
208
|
+
#: Findings that do not fail the calibration but change how it should be read.
|
|
209
|
+
notes: tuple[str, ...] = ()
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def error_rate(self) -> float:
|
|
213
|
+
attempted = self.n_examples + self.n_errored
|
|
214
|
+
return self.n_errored / attempted if attempted else 0.0
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def at_human_ceiling(self) -> bool:
|
|
218
|
+
"""True when the judge agrees with humans about as well as humans do.
|
|
219
|
+
|
|
220
|
+
Compared on the doubly-labelled subset only, and with a tolerance, because two
|
|
221
|
+
κ values from ~20 examples are not distinguishable to two decimal places.
|
|
222
|
+
"""
|
|
223
|
+
if self.human_kappa is None or self.judge_kappa_on_ceiling_subset is None:
|
|
224
|
+
return False
|
|
225
|
+
return self.judge_kappa_on_ceiling_subset >= self.human_kappa - 0.05
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True, slots=True)
|
|
229
|
+
class PositionBiasReport:
|
|
230
|
+
"""Order effects in a pairwise judge."""
|
|
231
|
+
|
|
232
|
+
n_pairs: int
|
|
233
|
+
#: Share of pairs where swapping the presentation order changed the winner.
|
|
234
|
+
inconsistency_rate: float
|
|
235
|
+
#: Share of pairs where the judge simply picked whatever came first.
|
|
236
|
+
first_position_rate: float
|
|
237
|
+
n_unresolved: int
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def biased(self) -> bool:
|
|
241
|
+
"""Whether the order effect is large enough to distrust the ranking.
|
|
242
|
+
|
|
243
|
+
0.2 rather than 0: some inconsistency is genuine indifference between two
|
|
244
|
+
similar outputs, and a judge is not required to be deterministic on a tie.
|
|
245
|
+
"""
|
|
246
|
+
return self.inconsistency_rate > 0.2 or self.first_position_rate > 0.2
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------- requirements
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True, slots=True)
|
|
253
|
+
class CalibrationRequirement:
|
|
254
|
+
"""What a gate set demands before it will trust a judge.
|
|
255
|
+
|
|
256
|
+
The defaults are the recommended values from docs/EVALUATION_ENGINE.md §5 for a
|
|
257
|
+
safety-relevant metric. `max_false_pass_rate` is deliberately much tighter than
|
|
258
|
+
`max_false_fail_rate`: a false pass ships a defect, a false fail annoys someone.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
min_agreement: float = 0.8
|
|
262
|
+
min_kappa: float | None = 0.6
|
|
263
|
+
max_false_pass_rate: float | None = 0.05
|
|
264
|
+
max_false_fail_rate: float | None = 0.20
|
|
265
|
+
min_examples: int = MIN_EXAMPLES
|
|
266
|
+
min_per_class: int = MIN_PER_CLASS
|
|
267
|
+
max_error_rate: float = 0.05
|
|
268
|
+
#: Refuse a judge whose order effects make its ranking unreliable.
|
|
269
|
+
allow_position_bias: bool = False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True, slots=True)
|
|
273
|
+
class RequirementCheck:
|
|
274
|
+
satisfied: bool
|
|
275
|
+
failures: tuple[str, ...]
|
|
276
|
+
warnings: tuple[str, ...]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def check_requirement( # noqa: PLR0912 — one branch per documented failure mode
|
|
280
|
+
report: CalibrationReport, requirement: CalibrationRequirement
|
|
281
|
+
) -> RequirementCheck:
|
|
282
|
+
"""Decide whether a calibration report satisfies a requirement.
|
|
283
|
+
|
|
284
|
+
Failures block. Warnings do not, but they name things that make the numbers less
|
|
285
|
+
trustworthy than they look — chiefly a confidence interval that straddles the
|
|
286
|
+
threshold, which means the honest answer is "label more examples", not "passed".
|
|
287
|
+
"""
|
|
288
|
+
failures: list[str] = []
|
|
289
|
+
warnings: list[str] = []
|
|
290
|
+
|
|
291
|
+
if report.n_examples < requirement.min_examples:
|
|
292
|
+
failures.append(
|
|
293
|
+
f"only {report.n_examples} usable labelled examples; "
|
|
294
|
+
f"{requirement.min_examples} are required. A judge certified on a small "
|
|
295
|
+
"set is not certified — κ on 30 examples has a ±0.25 interval."
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
thin = [c.label for c in report.per_class if c.support < requirement.min_per_class]
|
|
299
|
+
if thin:
|
|
300
|
+
failures.append(
|
|
301
|
+
f"under {requirement.min_per_class} examples for class(es) "
|
|
302
|
+
f"{', '.join(sorted(thin))}. Per-class agreement cannot be measured on a "
|
|
303
|
+
"handful of examples, and the rare class is usually the one that matters."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
if report.error_rate > requirement.max_error_rate:
|
|
307
|
+
failures.append(
|
|
308
|
+
f"{report.error_rate:.1%} of judge calls errored, above the "
|
|
309
|
+
f"{requirement.max_error_rate:.1%} limit. The calibration measured "
|
|
310
|
+
"whichever examples happened to succeed."
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if report.agreement < requirement.min_agreement:
|
|
314
|
+
failures.append(
|
|
315
|
+
f"agreement {report.agreement:.3f} is below the required "
|
|
316
|
+
f"{requirement.min_agreement:.3f}"
|
|
317
|
+
)
|
|
318
|
+
elif report.agreement_ci[0] < requirement.min_agreement:
|
|
319
|
+
warnings.append(
|
|
320
|
+
f"agreement {report.agreement:.3f} clears {requirement.min_agreement:.3f}, "
|
|
321
|
+
f"but its 95% interval [{report.agreement_ci[0]:.3f}, "
|
|
322
|
+
f"{report.agreement_ci[1]:.3f}] straddles the threshold. The set is too "
|
|
323
|
+
"small to resolve the question; label more examples."
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if requirement.min_kappa is not None:
|
|
327
|
+
if report.kappa is None:
|
|
328
|
+
failures.append(
|
|
329
|
+
f"κ could not be computed ({report.kappa_undefined_reason}), so the "
|
|
330
|
+
"agreement figure is uncorrected for chance and cannot be trusted "
|
|
331
|
+
"against a κ threshold."
|
|
332
|
+
)
|
|
333
|
+
elif report.kappa < requirement.min_kappa:
|
|
334
|
+
# The ceiling is the one thing that can excuse a low κ: a task humans
|
|
335
|
+
# cannot agree on is not a task a judge can be held to.
|
|
336
|
+
if report.at_human_ceiling:
|
|
337
|
+
warnings.append(
|
|
338
|
+
f"κ {report.kappa:.3f} is below the required "
|
|
339
|
+
f"{requirement.min_kappa:.3f}, but human annotators only reach "
|
|
340
|
+
f"{report.human_kappa:.3f} on the same examples. The judge is at "
|
|
341
|
+
"the ceiling of the task; the rubric is the thing to fix, not the "
|
|
342
|
+
"judge."
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
failures.append(
|
|
346
|
+
f"κ {report.kappa:.3f} is below the required {requirement.min_kappa:.3f}"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if (
|
|
350
|
+
requirement.max_false_pass_rate is not None
|
|
351
|
+
and report.false_pass_rate is not None
|
|
352
|
+
and report.false_pass_rate > requirement.max_false_pass_rate
|
|
353
|
+
):
|
|
354
|
+
failures.append(
|
|
355
|
+
f"false-pass rate {report.false_pass_rate:.3f} exceeds "
|
|
356
|
+
f"{requirement.max_false_pass_rate:.3f}. This judge passes work a "
|
|
357
|
+
"human rejected, which is the direction that ships defects."
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
if (
|
|
361
|
+
requirement.max_false_fail_rate is not None
|
|
362
|
+
and report.false_fail_rate is not None
|
|
363
|
+
and report.false_fail_rate > requirement.max_false_fail_rate
|
|
364
|
+
):
|
|
365
|
+
failures.append(
|
|
366
|
+
f"false-fail rate {report.false_fail_rate:.3f} exceeds "
|
|
367
|
+
f"{requirement.max_false_fail_rate:.3f}. A gate that blocks acceptable "
|
|
368
|
+
"work gets bypassed, and then it protects nothing."
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if (
|
|
372
|
+
report.position_bias is not None
|
|
373
|
+
and report.position_bias.biased
|
|
374
|
+
and not requirement.allow_position_bias
|
|
375
|
+
):
|
|
376
|
+
failures.append(
|
|
377
|
+
f"order effects: {report.position_bias.inconsistency_rate:.1%} of pairs "
|
|
378
|
+
f"changed winner when swapped, {report.position_bias.first_position_rate:.1%} "
|
|
379
|
+
"simply picked whichever came first. A pairwise ranking from this judge "
|
|
380
|
+
"measures presentation order."
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if report.scale_compression is not None and report.scale_compression < 0.5:
|
|
384
|
+
warnings.append(
|
|
385
|
+
f"the judge uses {report.scale_compression:.0%} of the spread the humans "
|
|
386
|
+
"use. Leniency clustering compresses the range, so a real regression can "
|
|
387
|
+
"fall below the judge's resolution."
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if report.verbosity_bias is not None and abs(report.verbosity_bias) > 0.3:
|
|
391
|
+
direction = "longer" if report.verbosity_bias > 0 else "shorter"
|
|
392
|
+
warnings.append(
|
|
393
|
+
f"verbosity bias {report.verbosity_bias:+.2f}: the judge scores {direction} "
|
|
394
|
+
"outputs more generously than the humans did, independent of quality."
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
return RequirementCheck(
|
|
398
|
+
satisfied=not failures, failures=tuple(failures), warnings=tuple(warnings)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ---------------------------------------------------------------------- the maths
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def confusion_matrix(
|
|
406
|
+
pairs: Sequence[tuple[str, str]], *, labels: Sequence[str] | None = None
|
|
407
|
+
) -> ConfusionMatrix:
|
|
408
|
+
"""Build a matrix from `(human, judge)` label pairs.
|
|
409
|
+
|
|
410
|
+
The label set is the union of both raters' labels, not just the humans'. A judge
|
|
411
|
+
that invents a label outside the schema must appear as a column rather than
|
|
412
|
+
vanishing from the matrix while still counting as a disagreement.
|
|
413
|
+
"""
|
|
414
|
+
observed = sorted({label for pair in pairs for label in pair})
|
|
415
|
+
ordered = tuple(labels) if labels is not None else tuple(observed)
|
|
416
|
+
if labels is not None:
|
|
417
|
+
ordered = (*ordered, *(label for label in observed if label not in ordered))
|
|
418
|
+
|
|
419
|
+
counts: dict[tuple[str, str], int] = {}
|
|
420
|
+
for human, judge in pairs:
|
|
421
|
+
counts[human, judge] = counts.get((human, judge), 0) + 1
|
|
422
|
+
return ConfusionMatrix(labels=ordered, counts=counts)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def observed_agreement(pairs: Sequence[tuple[str, str]]) -> float:
|
|
426
|
+
if not pairs:
|
|
427
|
+
return 0.0
|
|
428
|
+
return sum(1 for human, judge in pairs if human == judge) / len(pairs)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def cohens_kappa(
|
|
432
|
+
pairs: Sequence[tuple[str, str]],
|
|
433
|
+
*,
|
|
434
|
+
kind: KappaKind = "unweighted",
|
|
435
|
+
order: Sequence[str] | None = None,
|
|
436
|
+
) -> tuple[float | None, str | None]:
|
|
437
|
+
"""Cohen's κ, returning `(value, undefined_reason)`.
|
|
438
|
+
|
|
439
|
+
κ = (po - pe) / (1 - pe), where pe is the agreement expected from the raters'
|
|
440
|
+
marginal frequencies. It is `None` — never 0.0, never 1.0 — when pe = 1, which
|
|
441
|
+
happens whenever both raters used exactly one label. That case is a genuine
|
|
442
|
+
degenerate: perfect agreement carrying no information, since chance alone predicts
|
|
443
|
+
it perfectly. Reporting 1.0 there would certify a judge that answers the same thing
|
|
444
|
+
every time, and reporting 0.0 would reject a judge that was never wrong.
|
|
445
|
+
|
|
446
|
+
`kind` selects weighting for ordinal scales, which requires `order`. On a 1-5
|
|
447
|
+
rubric, unweighted κ treats "4 where the human said 5" exactly like "1 where the
|
|
448
|
+
human said 5", which is not a defensible way to grade a scale.
|
|
449
|
+
"""
|
|
450
|
+
if not pairs:
|
|
451
|
+
return None, "no labelled examples"
|
|
452
|
+
|
|
453
|
+
matrix = confusion_matrix(pairs, labels=order)
|
|
454
|
+
n = matrix.total
|
|
455
|
+
labels = matrix.labels
|
|
456
|
+
|
|
457
|
+
if kind != "unweighted":
|
|
458
|
+
if order is None:
|
|
459
|
+
msg = f"{kind} weighting needs an explicit ordinal `order`"
|
|
460
|
+
raise ValueError(msg)
|
|
461
|
+
missing = {label for pair in pairs for label in pair} - set(order)
|
|
462
|
+
if missing:
|
|
463
|
+
# A label outside the declared scale has no position, so no distance. That
|
|
464
|
+
# is a broken judge or a broken dataset, not something to average over.
|
|
465
|
+
return None, f"labels outside the declared ordinal scale: {sorted(missing)}"
|
|
466
|
+
|
|
467
|
+
weights = _weights(labels, kind)
|
|
468
|
+
|
|
469
|
+
observed = 0.0
|
|
470
|
+
expected = 0.0
|
|
471
|
+
for human in labels:
|
|
472
|
+
row = matrix.row_total(human)
|
|
473
|
+
if row == 0:
|
|
474
|
+
continue
|
|
475
|
+
for judge in labels:
|
|
476
|
+
column = matrix.column_total(judge)
|
|
477
|
+
weight = weights[human, judge]
|
|
478
|
+
observed += weight * matrix.get(human, judge) / n
|
|
479
|
+
expected += weight * (row / n) * (column / n)
|
|
480
|
+
|
|
481
|
+
if math.isclose(expected, 1.0, abs_tol=1e-12):
|
|
482
|
+
used = sorted({label for pair in pairs for label in pair})
|
|
483
|
+
return None, (
|
|
484
|
+
f"chance agreement is 1.0 because both raters used only {used!r}; "
|
|
485
|
+
"κ is undefined and the agreement figure carries no information"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
return (observed - expected) / (1 - expected), None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _weights(labels: Sequence[str], kind: KappaKind) -> dict[tuple[str, str], float]:
|
|
492
|
+
"""Agreement weights: 1 for a match, less for a near miss, 0 for the extremes."""
|
|
493
|
+
if kind == "unweighted":
|
|
494
|
+
return {(a, b): 1.0 if a == b else 0.0 for a in labels for b in labels}
|
|
495
|
+
|
|
496
|
+
positions = {label: index for index, label in enumerate(labels)}
|
|
497
|
+
span = max(1, len(labels) - 1)
|
|
498
|
+
power = 1 if kind == "linear" else 2
|
|
499
|
+
return {
|
|
500
|
+
(a, b): 1.0 - (abs(positions[a] - positions[b]) / span) ** power
|
|
501
|
+
for a in labels
|
|
502
|
+
for b in labels
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def directional_error_rates(
|
|
507
|
+
pairs: Sequence[tuple[str, str]], passing: Sequence[str]
|
|
508
|
+
) -> tuple[float | None, float | None]:
|
|
509
|
+
"""False-pass and false-fail rates under a pass/fail projection.
|
|
510
|
+
|
|
511
|
+
- **false pass** = judge passed / human failed, as a share of what humans failed.
|
|
512
|
+
The denominator is what humans failed, not the whole set, because the question is
|
|
513
|
+
"of the defects a human caught, how many would this judge wave through?"
|
|
514
|
+
- **false fail** = judge failed / human passed, as a share of what humans passed.
|
|
515
|
+
|
|
516
|
+
Each is `None` when its denominator is empty, because a rate over zero cases is not
|
|
517
|
+
zero — it is unmeasured, and a gate must not read the two the same way.
|
|
518
|
+
"""
|
|
519
|
+
passing_set = set(passing)
|
|
520
|
+
human_failed = [(h, j) for h, j in pairs if h not in passing_set]
|
|
521
|
+
human_passed = [(h, j) for h, j in pairs if h in passing_set]
|
|
522
|
+
|
|
523
|
+
false_pass = (
|
|
524
|
+
sum(1 for _, judge in human_failed if judge in passing_set) / len(human_failed)
|
|
525
|
+
if human_failed
|
|
526
|
+
else None
|
|
527
|
+
)
|
|
528
|
+
false_fail = (
|
|
529
|
+
sum(1 for _, judge in human_passed if judge not in passing_set) / len(human_passed)
|
|
530
|
+
if human_passed
|
|
531
|
+
else None
|
|
532
|
+
)
|
|
533
|
+
return false_pass, false_fail
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def per_class_breakdown(matrix: ConfusionMatrix) -> tuple[ClassBreakdown, ...]:
|
|
537
|
+
"""Recall and precision for each human label, plus its worst substitution."""
|
|
538
|
+
out: list[ClassBreakdown] = []
|
|
539
|
+
for label in matrix.labels:
|
|
540
|
+
support = matrix.row_total(label)
|
|
541
|
+
predicted = matrix.column_total(label)
|
|
542
|
+
hits = matrix.get(label, label)
|
|
543
|
+
|
|
544
|
+
confusions = [
|
|
545
|
+
(other, matrix.get(label, other)) for other in matrix.labels if other != label
|
|
546
|
+
]
|
|
547
|
+
confusions.sort(key=lambda item: (-item[1], item[0]))
|
|
548
|
+
top = confusions[0] if confusions and confusions[0][1] > 0 else None
|
|
549
|
+
|
|
550
|
+
out.append(
|
|
551
|
+
ClassBreakdown(
|
|
552
|
+
label=label,
|
|
553
|
+
support=support,
|
|
554
|
+
# Zero support means unmeasured, and 0.0 recall would read as "the judge
|
|
555
|
+
# missed all of them". Kept at 0.0 only when there was something to miss.
|
|
556
|
+
recall=hits / support if support else 0.0,
|
|
557
|
+
precision=hits / predicted if predicted else 0.0,
|
|
558
|
+
top_confusion=top,
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
return tuple(out)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def position_bias(probes: Sequence[PairwiseProbe]) -> PositionBiasReport | None:
|
|
565
|
+
"""Quantify order effects from comparisons run in both orders.
|
|
566
|
+
|
|
567
|
+
Pairwise judges without order swapping are measurably biased, which is why the
|
|
568
|
+
probe carries both directions. Unresolved pairs — where either direction failed to
|
|
569
|
+
produce a winner — are excluded from the rates and counted separately, so a judge
|
|
570
|
+
that mostly errors cannot look consistent.
|
|
571
|
+
"""
|
|
572
|
+
if not probes:
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
resolved = [p for p in probes if p.winner_ab is not None and p.winner_ba is not None]
|
|
576
|
+
unresolved = len(probes) - len(resolved)
|
|
577
|
+
if not resolved:
|
|
578
|
+
return PositionBiasReport(
|
|
579
|
+
n_pairs=0, inconsistency_rate=0.0, first_position_rate=0.0, n_unresolved=unresolved
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
inconsistent = sum(1 for p in resolved if not p.consistent)
|
|
583
|
+
first = sum(1 for p in resolved if p.chose_first_both_times)
|
|
584
|
+
return PositionBiasReport(
|
|
585
|
+
n_pairs=len(resolved),
|
|
586
|
+
inconsistency_rate=inconsistent / len(resolved),
|
|
587
|
+
first_position_rate=first / len(resolved),
|
|
588
|
+
n_unresolved=unresolved,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# ------------------------------------------------------------------ the entry point
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def calibrate(
|
|
596
|
+
labelled: Sequence[LabelledExample],
|
|
597
|
+
verdicts: Sequence[JudgeVerdict],
|
|
598
|
+
*,
|
|
599
|
+
passing_labels: Sequence[str] | None = None,
|
|
600
|
+
ordinal_order: Sequence[str] | None = None,
|
|
601
|
+
kappa_kind: KappaKind | None = None,
|
|
602
|
+
probes: Sequence[PairwiseProbe] = (),
|
|
603
|
+
bootstrap_resamples: int = 2_000,
|
|
604
|
+
seed: int = 42,
|
|
605
|
+
) -> CalibrationReport:
|
|
606
|
+
"""Compute a full calibration report.
|
|
607
|
+
|
|
608
|
+
`ordinal_order` turns on scale-aware maths: weighted κ, leniency, compression. Pass
|
|
609
|
+
it for a rubric judge and leave it out for a classifier, where "nearer" is
|
|
610
|
+
meaningless.
|
|
611
|
+
"""
|
|
612
|
+
by_id = {example.example_id: example for example in labelled}
|
|
613
|
+
notes: list[str] = []
|
|
614
|
+
|
|
615
|
+
usable: list[tuple[LabelledExample, JudgeVerdict]] = []
|
|
616
|
+
errored = 0
|
|
617
|
+
unknown_ids: list[str] = []
|
|
618
|
+
for verdict in verdicts:
|
|
619
|
+
example = by_id.get(verdict.example_id)
|
|
620
|
+
if example is None:
|
|
621
|
+
unknown_ids.append(verdict.example_id)
|
|
622
|
+
continue
|
|
623
|
+
if verdict.errored or verdict.label is None:
|
|
624
|
+
errored += 1
|
|
625
|
+
continue
|
|
626
|
+
usable.append((example, verdict))
|
|
627
|
+
|
|
628
|
+
if unknown_ids:
|
|
629
|
+
notes.append(
|
|
630
|
+
f"{len(unknown_ids)} judge verdict(s) referenced example ids absent from the "
|
|
631
|
+
f"labelled set (e.g. {unknown_ids[0]!r}) and were ignored."
|
|
632
|
+
)
|
|
633
|
+
judged_ids = {verdict.example_id for verdict in verdicts}
|
|
634
|
+
unjudged = [e.example_id for e in labelled if e.example_id not in judged_ids]
|
|
635
|
+
if unjudged:
|
|
636
|
+
# Silently reporting agreement over a subset would overstate coverage.
|
|
637
|
+
notes.append(f"{len(unjudged)} labelled example(s) were never judged and are excluded.")
|
|
638
|
+
|
|
639
|
+
pairs = [(example.reference_label, verdict.label or "") for example, verdict in usable]
|
|
640
|
+
matrix = confusion_matrix(pairs, labels=ordinal_order)
|
|
641
|
+
|
|
642
|
+
kind: KappaKind = kappa_kind or ("quadratic" if ordinal_order else "unweighted")
|
|
643
|
+
kappa, undefined = cohens_kappa(pairs, kind=kind, order=ordinal_order)
|
|
644
|
+
|
|
645
|
+
agreement = observed_agreement(pairs)
|
|
646
|
+
hits = sum(1 for human, judge in pairs if human == judge)
|
|
647
|
+
agreement_ci = wilson_ci(hits, len(pairs)) if pairs else (0.0, 1.0)
|
|
648
|
+
kappa_ci = (
|
|
649
|
+
_kappa_ci(pairs, kind=kind, order=ordinal_order, resamples=bootstrap_resamples, seed=seed)
|
|
650
|
+
if kappa is not None
|
|
651
|
+
else None
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
false_pass, false_fail = (
|
|
655
|
+
directional_error_rates(pairs, passing_labels) if passing_labels else (None, None)
|
|
656
|
+
)
|
|
657
|
+
if passing_labels and false_pass is None:
|
|
658
|
+
notes.append(
|
|
659
|
+
"no human-failed examples, so the false-pass rate is unmeasured. A "
|
|
660
|
+
"calibration set with no negatives cannot show whether the judge catches "
|
|
661
|
+
"anything."
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
human_kappa, judge_on_subset, n_ceiling, ceiling_note = _ceiling(
|
|
665
|
+
usable, kind=kind, order=ordinal_order
|
|
666
|
+
)
|
|
667
|
+
if ceiling_note:
|
|
668
|
+
notes.append(ceiling_note)
|
|
669
|
+
|
|
670
|
+
costs = [verdict.cost for _, verdict in usable]
|
|
671
|
+
latencies = [float(verdict.latency_ms) for _, verdict in usable]
|
|
672
|
+
|
|
673
|
+
leniency, compression = _scale_effects(pairs, ordinal_order)
|
|
674
|
+
verbosity = _verbosity_bias(usable, ordinal_order)
|
|
675
|
+
|
|
676
|
+
return CalibrationReport(
|
|
677
|
+
n_examples=len(usable),
|
|
678
|
+
n_errored=errored,
|
|
679
|
+
agreement=agreement,
|
|
680
|
+
agreement_ci=agreement_ci,
|
|
681
|
+
kappa=kappa,
|
|
682
|
+
kappa_ci=kappa_ci,
|
|
683
|
+
kappa_kind=kind,
|
|
684
|
+
kappa_undefined_reason=undefined,
|
|
685
|
+
false_pass_rate=false_pass,
|
|
686
|
+
false_fail_rate=false_fail,
|
|
687
|
+
confusion=matrix,
|
|
688
|
+
per_class=per_class_breakdown(matrix),
|
|
689
|
+
human_kappa=human_kappa,
|
|
690
|
+
judge_kappa_on_ceiling_subset=judge_on_subset,
|
|
691
|
+
n_ceiling_examples=n_ceiling,
|
|
692
|
+
mean_cost=(sum(costs, Decimal(0)) / len(costs)) if costs else Decimal(0),
|
|
693
|
+
total_cost=sum(costs, Decimal(0)),
|
|
694
|
+
p50_latency_ms=percentile(latencies, 50) if latencies else 0.0,
|
|
695
|
+
p95_latency_ms=percentile(latencies, 95) if latencies else 0.0,
|
|
696
|
+
leniency=leniency,
|
|
697
|
+
scale_compression=compression,
|
|
698
|
+
verbosity_bias=verbosity,
|
|
699
|
+
position_bias=position_bias(probes),
|
|
700
|
+
notes=tuple(notes),
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _kappa_ci(
|
|
705
|
+
pairs: Sequence[tuple[str, str]],
|
|
706
|
+
*,
|
|
707
|
+
kind: KappaKind,
|
|
708
|
+
order: Sequence[str] | None,
|
|
709
|
+
resamples: int,
|
|
710
|
+
seed: int,
|
|
711
|
+
confidence: float = 0.95,
|
|
712
|
+
) -> tuple[float, float]:
|
|
713
|
+
"""Percentile bootstrap interval for κ.
|
|
714
|
+
|
|
715
|
+
Bootstrapped rather than using the large-sample analytic standard error: the
|
|
716
|
+
analytic formula is only trustworthy well above the sample sizes calibration sets
|
|
717
|
+
actually reach, and resampling 100 pairs 2 000 times costs milliseconds. Seeded, so
|
|
718
|
+
the same set always yields the same interval.
|
|
719
|
+
"""
|
|
720
|
+
if len(pairs) < 2:
|
|
721
|
+
return 0.0, 0.0
|
|
722
|
+
|
|
723
|
+
rng = random.Random(seed) # noqa: S311 — resampling, not security
|
|
724
|
+
n = len(pairs)
|
|
725
|
+
values: list[float] = []
|
|
726
|
+
for _ in range(resamples):
|
|
727
|
+
sample = [pairs[rng.randrange(n)] for _ in range(n)]
|
|
728
|
+
value, _reason = cohens_kappa(sample, kind=kind, order=order)
|
|
729
|
+
# A resample can be degenerate (every draw the same label). Skipping those
|
|
730
|
+
# rather than substituting 0 keeps the interval about κ instead of about how
|
|
731
|
+
# often the bootstrap collapsed.
|
|
732
|
+
if value is not None:
|
|
733
|
+
values.append(value)
|
|
734
|
+
|
|
735
|
+
if not values:
|
|
736
|
+
return 0.0, 0.0
|
|
737
|
+
values.sort()
|
|
738
|
+
alpha = (1 - confidence) / 2
|
|
739
|
+
low = values[max(0, int(alpha * len(values)) - 1)]
|
|
740
|
+
high = values[min(len(values) - 1, int((1 - alpha) * len(values)))]
|
|
741
|
+
return low, high
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _ceiling(
|
|
745
|
+
usable: Sequence[tuple[LabelledExample, JudgeVerdict]],
|
|
746
|
+
*,
|
|
747
|
+
kind: KappaKind,
|
|
748
|
+
order: Sequence[str] | None,
|
|
749
|
+
) -> tuple[float | None, float | None, int, str | None]:
|
|
750
|
+
"""Human-human κ, and the judge's κ on exactly the same examples.
|
|
751
|
+
|
|
752
|
+
The restriction matters and is easy to get wrong. Doubly-labelled examples are the
|
|
753
|
+
ones deliberately chosen to include boundary cases, so they are systematically
|
|
754
|
+
harder than the rest. Comparing the judge's κ over the whole set against the
|
|
755
|
+
humans' κ over that hard subset flatters the judge — it would let a judge look like
|
|
756
|
+
it had reached a ceiling it was never measured against.
|
|
757
|
+
"""
|
|
758
|
+
subset = [
|
|
759
|
+
(example, verdict) for example, verdict in usable if example.second_human_label is not None
|
|
760
|
+
]
|
|
761
|
+
if not subset:
|
|
762
|
+
return (
|
|
763
|
+
None,
|
|
764
|
+
None,
|
|
765
|
+
0,
|
|
766
|
+
(
|
|
767
|
+
"no examples carry a second annotator's label, so there is no human "
|
|
768
|
+
"agreement ceiling to compare against. Double-label at least "
|
|
769
|
+
f"{MIN_CEILING_EXAMPLES} examples to find out whether the task is one "
|
|
770
|
+
"humans agree on at all."
|
|
771
|
+
),
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
human_pairs = [(e.human_label, e.second_human_label or "") for e, _ in subset]
|
|
775
|
+
judge_pairs = [(e.reference_label, v.label or "") for e, v in subset]
|
|
776
|
+
|
|
777
|
+
human_kappa, _ = cohens_kappa(human_pairs, kind=kind, order=order)
|
|
778
|
+
judge_kappa, _ = cohens_kappa(judge_pairs, kind=kind, order=order)
|
|
779
|
+
|
|
780
|
+
note = None
|
|
781
|
+
if len(subset) < MIN_CEILING_EXAMPLES:
|
|
782
|
+
note = (
|
|
783
|
+
f"the human ceiling rests on {len(subset)} doubly-labelled example(s), "
|
|
784
|
+
f"below the {MIN_CEILING_EXAMPLES} needed for it to mean much."
|
|
785
|
+
)
|
|
786
|
+
return human_kappa, judge_kappa, len(subset), note
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _scale_effects(
|
|
790
|
+
pairs: Sequence[tuple[str, str]], order: Sequence[str] | None
|
|
791
|
+
) -> tuple[float | None, float | None]:
|
|
792
|
+
"""Leniency and scale compression, for ordinal judges only."""
|
|
793
|
+
if not order or not pairs:
|
|
794
|
+
return None, None
|
|
795
|
+
|
|
796
|
+
positions = {label: index for index, label in enumerate(order)}
|
|
797
|
+
human = [positions[h] for h, _ in pairs if h in positions]
|
|
798
|
+
judge = [positions[j] for _, j in pairs if j in positions]
|
|
799
|
+
if len(human) != len(pairs) or len(judge) != len(pairs):
|
|
800
|
+
return None, None
|
|
801
|
+
|
|
802
|
+
human_spread = stddev(human)
|
|
803
|
+
leniency = mean(judge) - mean(human)
|
|
804
|
+
# A human set with no spread cannot say whether the judge compressed anything.
|
|
805
|
+
compression = (stddev(judge) / human_spread) if human_spread > 0 else None
|
|
806
|
+
return leniency, compression
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _verbosity_bias(
|
|
810
|
+
usable: Sequence[tuple[LabelledExample, JudgeVerdict]], order: Sequence[str] | None
|
|
811
|
+
) -> float | None:
|
|
812
|
+
"""Correlation between output length and the judge's signed error.
|
|
813
|
+
|
|
814
|
+
Signed error, not raw score: a positive correlation between length and *score*
|
|
815
|
+
might just mean longer answers are genuinely better. Correlating length with
|
|
816
|
+
judge-minus-human isolates the part the humans did not agree was quality — which is
|
|
817
|
+
the definition of the bias.
|
|
818
|
+
"""
|
|
819
|
+
if not order:
|
|
820
|
+
return None
|
|
821
|
+
|
|
822
|
+
positions = {label: index for index, label in enumerate(order)}
|
|
823
|
+
lengths: list[float] = []
|
|
824
|
+
errors: list[float] = []
|
|
825
|
+
for example, verdict in usable:
|
|
826
|
+
if example.output_length is None or verdict.label not in positions:
|
|
827
|
+
continue
|
|
828
|
+
if example.reference_label not in positions:
|
|
829
|
+
continue
|
|
830
|
+
lengths.append(float(example.output_length))
|
|
831
|
+
errors.append(float(positions[verdict.label] - positions[example.reference_label]))
|
|
832
|
+
|
|
833
|
+
if len(lengths) < 3:
|
|
834
|
+
return None
|
|
835
|
+
return _pearson(lengths, errors)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _pearson(xs: Sequence[float], ys: Sequence[float]) -> float | None:
|
|
839
|
+
"""Pearson correlation, or None when either series is constant."""
|
|
840
|
+
n = len(xs)
|
|
841
|
+
if n < 2:
|
|
842
|
+
return None
|
|
843
|
+
mx, my = mean(xs), mean(ys)
|
|
844
|
+
numerator = math.fsum((x - mx) * (y - my) for x, y in zip(xs, ys, strict=True))
|
|
845
|
+
dx = math.sqrt(math.fsum((x - mx) ** 2 for x in xs))
|
|
846
|
+
dy = math.sqrt(math.fsum((y - my) ** 2 for y in ys))
|
|
847
|
+
if dx == 0 or dy == 0:
|
|
848
|
+
return None
|
|
849
|
+
return numerator / (dx * dy)
|