assay-engine 0.5.0.dev2__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.
- assay/__init__.py +83 -0
- assay/_cli_app.py +102 -0
- assay/_cli_io.py +130 -0
- assay/_json.py +34 -0
- assay/_optional.py +69 -0
- assay/_version.py +5 -0
- assay/additive.py +104 -0
- assay/agreement.py +312 -0
- assay/calibration.py +167 -0
- assay/cli.py +55 -0
- assay/compose.py +31 -0
- assay/composite.py +228 -0
- assay/contracts.py +886 -0
- assay/errors.py +166 -0
- assay/limits.py +13 -0
- assay/measurement.py +1325 -0
- assay/metrics.py +238 -0
- assay/minimum.py +82 -0
- assay/models.py +111 -0
- assay/normalize.py +74 -0
- assay/py.typed +0 -0
- assay/ranking.py +361 -0
- assay/settings.py +90 -0
- assay/uncertainty.py +190 -0
- assay/weighted_mean.py +109 -0
- assay_engine-0.5.0.dev2.dist-info/METADATA +250 -0
- assay_engine-0.5.0.dev2.dist-info/RECORD +30 -0
- assay_engine-0.5.0.dev2.dist-info/WHEEL +4 -0
- assay_engine-0.5.0.dev2.dist-info/entry_points.txt +2 -0
- assay_engine-0.5.0.dev2.dist-info/licenses/LICENSE +21 -0
assay/agreement.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Ordinal agreement — did two graders mean the same thing, or just share a habit?
|
|
2
|
+
|
|
3
|
+
``metrics.py`` scores predictions against ground truth. This module scores two *raters*
|
|
4
|
+
against each other, when what they emit is a band on an ordered scale (weak / moderate /
|
|
5
|
+
strong) rather than a number. That shape has no ground truth to score against: nobody
|
|
6
|
+
knows the true band, only whether two independent graders landed in the same place.
|
|
7
|
+
|
|
8
|
+
**Why percent agreement is the wrong statistic here**, and the reason this module exists:
|
|
9
|
+
|
|
10
|
+
1. It is blind to *distance*. On a three-band scale, one grader saying "strong" while the
|
|
11
|
+
other says "moderate" is a near miss; "strong" against "weak" is a total miss. Percent
|
|
12
|
+
agreement scores both as simply "not a match".
|
|
13
|
+
2. It counts agreement that chance alone would produce. Two graders who both call 90% of
|
|
14
|
+
everything "weak" will match about 80% of the time while agreeing about nothing.
|
|
15
|
+
|
|
16
|
+
One-line definitions, since none of these terms carry themselves:
|
|
17
|
+
|
|
18
|
+
- **quadratic-weighted Cohen's kappa** — agreement after subtracting the agreement two
|
|
19
|
+
independent graders with these same habits would have produced, with each miss charged
|
|
20
|
+
by the *square* of how many bands apart it was. 1.0 is perfect, 0.0 is exactly chance,
|
|
21
|
+
and negative means the two graders did worse than if they had ignored each other.
|
|
22
|
+
- **Kendall's tau-b** — of every pair of items, how often the two graders put them in the
|
|
23
|
+
same relative order. +1 perfectly concordant, -1 perfectly inverted. The ``b`` is the
|
|
24
|
+
tie correction, which a three-band scale over many items needs badly.
|
|
25
|
+
|
|
26
|
+
They answer different questions and the report carries both: two graders who agree on
|
|
27
|
+
every *ordering* but sit one band apart on the level score tau-b 1.0 and kappa 2/3.
|
|
28
|
+
|
|
29
|
+
*The engines.* Kappa is ``sklearn.metrics.cohen_kappa_score(weights="quadratic")`` and
|
|
30
|
+
tau-b is ``scipy.stats.kendalltau(variant="b")`` — both already pinned dependencies, both
|
|
31
|
+
the reference implementation their field validates against, and neither needs a line of
|
|
32
|
+
correction at the boundary. Assay contributes what they do not: a declared band order
|
|
33
|
+
that cannot be guessed wrong, a refusal for every input whose answer would be undefined,
|
|
34
|
+
and an uncertainty interval on the same honesty floor the rest of the package uses.
|
|
35
|
+
|
|
36
|
+
*The trap this module is built around.* ``cohen_kappa_score`` derives the ordinal
|
|
37
|
+
distance between two bands from their **positions in its ``labels`` argument**. Leave
|
|
38
|
+
that argument off and it sorts the band names alphabetically, so "moderate < strong <
|
|
39
|
+
weak" — and the same ratings come back with a completely different, entirely plausible
|
|
40
|
+
number. Sorted-by-accident is not a scale. The caller declares the order, always.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import math
|
|
46
|
+
from collections.abc import Mapping, Sequence
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from typing import TYPE_CHECKING, Protocol, cast
|
|
49
|
+
|
|
50
|
+
from pydantic import BaseModel, ConfigDict
|
|
51
|
+
|
|
52
|
+
from assay._optional import call_dependency, dependency_failed, load_callable, load_module
|
|
53
|
+
from assay.errors import InvalidAgreementRequest
|
|
54
|
+
from assay.limits import MAX_ITEMS, MAX_SCALE_LEVELS
|
|
55
|
+
from assay.models import ItemRating
|
|
56
|
+
from assay.uncertainty import Estimate, mean_interval
|
|
57
|
+
|
|
58
|
+
if TYPE_CHECKING:
|
|
59
|
+
from assay.settings import AssaySettings
|
|
60
|
+
|
|
61
|
+
type Scale = Sequence[str]
|
|
62
|
+
"""The band names in order, weakest first. The order IS the measurement."""
|
|
63
|
+
|
|
64
|
+
_MIN_LEVELS = 2
|
|
65
|
+
"""Below two bands there is nothing to disagree about, and the quadratic weight's
|
|
66
|
+
``L - 1`` denominator is zero."""
|
|
67
|
+
|
|
68
|
+
_KAPPA_UNDEFINED = (
|
|
69
|
+
"both raters put every item in the same single band, so agreement by chance is total "
|
|
70
|
+
"and kappa's correction divides by zero"
|
|
71
|
+
)
|
|
72
|
+
_TAU_UNDEFINED = (
|
|
73
|
+
"at least one rater used a single band for every item, so there is no rank variation "
|
|
74
|
+
"for tau-b to concord"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _StatisticResult(Protocol):
|
|
79
|
+
statistic: object
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"AgreementReport",
|
|
84
|
+
"Scale",
|
|
85
|
+
"agreement_report",
|
|
86
|
+
"kendall_tau_b",
|
|
87
|
+
"percent_agreement",
|
|
88
|
+
"quadratic_kappa",
|
|
89
|
+
"weighted_agreement",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AgreementReport(BaseModel):
|
|
94
|
+
"""Two graders on one ordinal scale, and how much of their agreement is real.
|
|
95
|
+
|
|
96
|
+
``weighted_agreement_interval`` is a bootstrap confidence interval over the per-item
|
|
97
|
+
quadratic agreement, or an ``Abstention`` when there are fewer items than the sample
|
|
98
|
+
floor — the same uncertainty story the classification and ranking faces tell, not a
|
|
99
|
+
third one. It is deliberately NOT an interval on kappa: kappa is not the mean of any
|
|
100
|
+
per-item quantity, so no bootstrap of a mean can put an interval on it, and pairing
|
|
101
|
+
it with one would be exactly the fake precision this package refuses to print.
|
|
102
|
+
|
|
103
|
+
``quadratic_kappa`` and ``kendall_tau_b`` are ``None`` when the ratings are too
|
|
104
|
+
degenerate for the statistic to exist, and the matching ``*_undefined_reason`` says
|
|
105
|
+
which degeneracy it was. A bare ``None`` reads as "not computed"; the reason says
|
|
106
|
+
"cannot exist", which is a different fact about the data."""
|
|
107
|
+
|
|
108
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
109
|
+
|
|
110
|
+
scale: tuple[str, ...]
|
|
111
|
+
n_items: int
|
|
112
|
+
n_exact_matches: int
|
|
113
|
+
percent_agreement: float
|
|
114
|
+
weighted_agreement: float
|
|
115
|
+
quadratic_kappa: float | None
|
|
116
|
+
kappa_undefined_reason: str | None
|
|
117
|
+
kendall_tau_b: float | None
|
|
118
|
+
tau_undefined_reason: str | None
|
|
119
|
+
weighted_agreement_interval: Estimate
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _require_scale(scale: Scale) -> None:
|
|
123
|
+
if not _MIN_LEVELS <= len(scale) <= MAX_SCALE_LEVELS:
|
|
124
|
+
raise InvalidAgreementRequest(
|
|
125
|
+
f"an ordinal scale needs at least {_MIN_LEVELS} bands, got {len(scale)}"
|
|
126
|
+
)
|
|
127
|
+
if len(set(scale)) != len(scale):
|
|
128
|
+
raise InvalidAgreementRequest("the scale names the same band twice")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _require_paired(rater_a: Sequence[str], rater_b: Sequence[str]) -> None:
|
|
132
|
+
if len(rater_a) != len(rater_b):
|
|
133
|
+
raise InvalidAgreementRequest(
|
|
134
|
+
f"the raters graded different numbers of items: {len(rater_a)} vs {len(rater_b)}"
|
|
135
|
+
)
|
|
136
|
+
if not rater_a:
|
|
137
|
+
raise InvalidAgreementRequest("no items were graded; there is nothing to agree about")
|
|
138
|
+
if len(rater_a) > MAX_ITEMS:
|
|
139
|
+
raise InvalidAgreementRequest
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _ordinals(ratings: Sequence[str], positions: Mapping[str, int]) -> list[int]:
|
|
143
|
+
"""Band names as positions on the declared scale, refusing any band not on it.
|
|
144
|
+
|
|
145
|
+
The refusal is load-bearing, not defensive. ``cohen_kappa_score`` silently DROPS
|
|
146
|
+
every row whose label is outside its ``labels`` argument, so an unknown band would
|
|
147
|
+
return a perfectly healthy number computed over fewer items than were handed in."""
|
|
148
|
+
unknown = sorted(set(ratings) - set(positions))
|
|
149
|
+
if unknown:
|
|
150
|
+
raise InvalidAgreementRequest(f"bands that are not on the declared scale: {unknown}")
|
|
151
|
+
return [positions[band] for band in ratings]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _validate(
|
|
155
|
+
rater_a: Sequence[str], rater_b: Sequence[str], scale: Scale
|
|
156
|
+
) -> tuple[list[int], list[int]]:
|
|
157
|
+
"""Check everything, then return both raters as positions on the declared scale."""
|
|
158
|
+
_require_scale(scale)
|
|
159
|
+
_require_paired(rater_a, rater_b)
|
|
160
|
+
positions = {band: index for index, band in enumerate(scale)}
|
|
161
|
+
return _ordinals(rater_a, positions), _ordinals(rater_b, positions)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _per_item_weights(rater_a: Sequence[int], rater_b: Sequence[int], levels: int) -> list[float]:
|
|
165
|
+
"""Cohen's quadratic weight per item: ``1 - ((i - j) / (L - 1)) ** 2``.
|
|
166
|
+
|
|
167
|
+
1.0 for an exact match, falling with the SQUARE of the distance between bands — on a
|
|
168
|
+
three-band scale an adjacent miss keeps 0.75 and an opposite-ends miss keeps 0.0."""
|
|
169
|
+
span = levels - 1
|
|
170
|
+
return [1.0 - ((a - b) / span) ** 2 for a, b in zip(rater_a, rater_b, strict=True)]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def percent_agreement(rater_a: Sequence[str], rater_b: Sequence[str], *, scale: Scale) -> float:
|
|
174
|
+
"""The fraction of items both raters put in exactly the same band.
|
|
175
|
+
|
|
176
|
+
Carried because it is the number people reach for — and because the report exists to
|
|
177
|
+
show, side by side, why it is not enough."""
|
|
178
|
+
load_module("numpy")
|
|
179
|
+
ordinals_a, ordinals_b = _validate(rater_a, rater_b, scale)
|
|
180
|
+
matches = sum(a == b for a, b in zip(ordinals_a, ordinals_b, strict=True))
|
|
181
|
+
return matches / len(ordinals_a)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def weighted_agreement(rater_a: Sequence[str], rater_b: Sequence[str], *, scale: Scale) -> float:
|
|
185
|
+
"""Mean per-item quadratic agreement: like percent agreement, but a near miss counts.
|
|
186
|
+
|
|
187
|
+
Still uncorrected for chance — that correction is what kappa adds on top of it."""
|
|
188
|
+
ordinals_a, ordinals_b = _validate(rater_a, rater_b, scale)
|
|
189
|
+
return _numpy_mean(_per_item_weights(ordinals_a, ordinals_b, len(scale)))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def quadratic_kappa(
|
|
193
|
+
rater_a: Sequence[str], rater_b: Sequence[str], *, scale: Scale
|
|
194
|
+
) -> float | None:
|
|
195
|
+
"""Sklearn quadratic kappa, or ``None`` when chance agreement is total."""
|
|
196
|
+
_validate(rater_a, rater_b, scale)
|
|
197
|
+
if len(set(rater_a) | set(rater_b)) < _MIN_LEVELS:
|
|
198
|
+
return None
|
|
199
|
+
return _kappa_score(rater_a, rater_b, scale)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _kappa_score(rater_a: Sequence[str], rater_b: Sequence[str], scale: Scale) -> float:
|
|
203
|
+
return _finite_float(
|
|
204
|
+
_call(
|
|
205
|
+
"sklearn.metrics",
|
|
206
|
+
"cohen_kappa_score",
|
|
207
|
+
rater_a,
|
|
208
|
+
rater_b,
|
|
209
|
+
labels=list(scale),
|
|
210
|
+
weights="quadratic",
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def kendall_tau_b(rater_a: Sequence[str], rater_b: Sequence[str], *, scale: Scale) -> float | None:
|
|
216
|
+
"""Tie-corrected rank concordance, or ``None`` for a constant rater."""
|
|
217
|
+
ordinals_a, ordinals_b = _validate(rater_a, rater_b, scale)
|
|
218
|
+
if min(len(set(ordinals_a)), len(set(ordinals_b))) < _MIN_LEVELS:
|
|
219
|
+
return None
|
|
220
|
+
raw = _call("scipy.stats", "kendalltau", ordinals_a, ordinals_b, variant="b")
|
|
221
|
+
statistic = call_dependency(_statistic, raw)
|
|
222
|
+
if dependency_failed(statistic):
|
|
223
|
+
raise InvalidAgreementRequest
|
|
224
|
+
return _finite_float(statistic)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _statistic(value: object) -> object:
|
|
228
|
+
return cast(_StatisticResult, value).statistic
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _call(module: str, name: str, *args: object, **kwargs: object) -> object:
|
|
232
|
+
result = call_dependency(load_callable(module, name), *args, **kwargs)
|
|
233
|
+
if dependency_failed(result):
|
|
234
|
+
raise InvalidAgreementRequest
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _finite_float(value: object) -> float:
|
|
239
|
+
converted = call_dependency(float, value)
|
|
240
|
+
if dependency_failed(converted) or not math.isfinite(cast(float, converted)):
|
|
241
|
+
raise InvalidAgreementRequest
|
|
242
|
+
return cast(float, converted)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _numpy_mean(values: Sequence[float]) -> float:
|
|
246
|
+
return _finite_float(_call("numpy", "mean", values))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _require_distinct_items(ratings: Sequence[ItemRating]) -> None:
|
|
250
|
+
items = [row.item for row in ratings]
|
|
251
|
+
if len(set(items)) != len(items):
|
|
252
|
+
raise InvalidAgreementRequest("the same item is graded twice")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _interval(per_item: Sequence[float], settings: AssaySettings) -> Estimate:
|
|
256
|
+
return mean_interval(
|
|
257
|
+
per_item,
|
|
258
|
+
min_samples=settings.min_samples,
|
|
259
|
+
n_resamples=settings.bootstrap_resamples,
|
|
260
|
+
confidence_level=settings.confidence_level,
|
|
261
|
+
seed=settings.bootstrap_seed,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True)
|
|
266
|
+
class _ReportInputs:
|
|
267
|
+
rater_a: tuple[str, ...]
|
|
268
|
+
rater_b: tuple[str, ...]
|
|
269
|
+
scale: tuple[str, ...]
|
|
270
|
+
per_item: tuple[float, ...]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _statistics(data: _ReportInputs) -> tuple[float | None, float | None]:
|
|
274
|
+
kappa = quadratic_kappa(data.rater_a, data.rater_b, scale=data.scale)
|
|
275
|
+
tau = kendall_tau_b(data.rater_a, data.rater_b, scale=data.scale)
|
|
276
|
+
return kappa, tau
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _report(data: _ReportInputs, settings: AssaySettings) -> AgreementReport:
|
|
280
|
+
kappa, tau = _statistics(data)
|
|
281
|
+
return AgreementReport(
|
|
282
|
+
scale=data.scale,
|
|
283
|
+
n_items=len(data.per_item),
|
|
284
|
+
n_exact_matches=sum(a == b for a, b in zip(data.rater_a, data.rater_b, strict=True)),
|
|
285
|
+
percent_agreement=percent_agreement(data.rater_a, data.rater_b, scale=data.scale),
|
|
286
|
+
weighted_agreement=_numpy_mean(data.per_item),
|
|
287
|
+
quadratic_kappa=kappa,
|
|
288
|
+
kappa_undefined_reason=None if kappa is not None else _KAPPA_UNDEFINED,
|
|
289
|
+
kendall_tau_b=tau,
|
|
290
|
+
tau_undefined_reason=None if tau is not None else _TAU_UNDEFINED,
|
|
291
|
+
weighted_agreement_interval=_interval(data.per_item, settings),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _report_inputs(ratings: Sequence[ItemRating], scale: Scale) -> _ReportInputs:
|
|
296
|
+
_require_distinct_items(ratings)
|
|
297
|
+
rater_a = [row.rater_a for row in ratings]
|
|
298
|
+
rater_b = [row.rater_b for row in ratings]
|
|
299
|
+
ordinals_a, ordinals_b = _validate(rater_a, rater_b, scale)
|
|
300
|
+
weights = _per_item_weights(ordinals_a, ordinals_b, len(scale))
|
|
301
|
+
return _ReportInputs(tuple(rater_a), tuple(rater_b), tuple(scale), tuple(weights))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def agreement_report(
|
|
305
|
+
ratings: Sequence[ItemRating], *, scale: Scale, settings: AssaySettings
|
|
306
|
+
) -> AgreementReport:
|
|
307
|
+
"""Score one set of doubly-graded items: the counts, both statistics, and an interval.
|
|
308
|
+
|
|
309
|
+
``ratings`` is item-keyed rather than two loose parallel lists, because the item id is
|
|
310
|
+
what makes "the same item graded twice" detectable — a duplicate would let one
|
|
311
|
+
disputed item vote twice and quietly reweight the whole measurement."""
|
|
312
|
+
return _report(_report_inputs(ratings, scale), settings)
|
assay/calibration.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Calibration evidence: Expected Calibration Error (ECE), a reliability diagram,
|
|
2
|
+
and the Brier score.
|
|
3
|
+
|
|
4
|
+
Reliability points come from ``sklearn.calibration.calibration_curve`` (which drops
|
|
5
|
+
empty bins), bin populations from ``numpy.histogram`` over the same uniform edges,
|
|
6
|
+
and Brier from ``sklearn.metrics.brier_score_loss``. ECE is the population-weighted
|
|
7
|
+
gap between predicted confidence and observed frequency."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import SupportsInt, cast
|
|
15
|
+
|
|
16
|
+
from assay._optional import call_dependency, dependency_failed, load_callable
|
|
17
|
+
from assay.errors import InvalidScoreRequest
|
|
18
|
+
from assay.limits import MAX_CALIBRATION_BINS, MAX_ITEMS
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ReliabilityBin:
|
|
23
|
+
"""One reliability-diagram point."""
|
|
24
|
+
|
|
25
|
+
mean_predicted: float
|
|
26
|
+
fraction_positive: float
|
|
27
|
+
count: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CalibrationReport:
|
|
32
|
+
"""ECE, Brier, and the reliability diagram for a set of predictions."""
|
|
33
|
+
|
|
34
|
+
ece: float
|
|
35
|
+
brier: float
|
|
36
|
+
bins: tuple[ReliabilityBin, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _validate_shape(y_true: Sequence[int], y_score: Sequence[float], n_bins: int) -> None:
|
|
40
|
+
if len(y_true) != len(y_score) or not y_true:
|
|
41
|
+
raise InvalidScoreRequest
|
|
42
|
+
if len(y_true) > MAX_ITEMS:
|
|
43
|
+
raise InvalidScoreRequest
|
|
44
|
+
_validate_bin_count(n_bins)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_bin_count(n_bins: int) -> None:
|
|
48
|
+
if isinstance(n_bins, bool) or not 0 < n_bins <= MAX_CALIBRATION_BINS:
|
|
49
|
+
raise InvalidScoreRequest
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate_labels(y_true: Sequence[int]) -> None:
|
|
53
|
+
if set(y_true) - {0, 1}:
|
|
54
|
+
raise InvalidScoreRequest
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _validate_scores(y_score: Sequence[float]) -> None:
|
|
58
|
+
if not all(math.isfinite(score) and 0.0 <= score <= 1.0 for score in y_score):
|
|
59
|
+
raise InvalidScoreRequest
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _validate(y_true: Sequence[int], y_score: Sequence[float], n_bins: int) -> None:
|
|
63
|
+
_validate_shape(y_true, y_score, n_bins)
|
|
64
|
+
_validate_labels(y_true)
|
|
65
|
+
_validate_scores(y_score)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _bin_populations(score_arr: object, n_bins: int) -> list[int]:
|
|
69
|
+
# Bin with sklearn's own scheme (searchsorted on interior edges) rather than a
|
|
70
|
+
# parallel np.histogram: at an exact bin edge the two disagree, which would
|
|
71
|
+
# misalign these counts with calibration_curve's non-empty bins.
|
|
72
|
+
edges = _call("numpy", "linspace", 0.0, 1.0, n_bins + 1)
|
|
73
|
+
interior = call_dependency(_interior, edges)
|
|
74
|
+
if dependency_failed(interior):
|
|
75
|
+
raise InvalidScoreRequest
|
|
76
|
+
bin_ids = _call("numpy", "searchsorted", interior, score_arr)
|
|
77
|
+
counts = _call("numpy", "bincount", bin_ids, minlength=n_bins)
|
|
78
|
+
converted = call_dependency(_positive_ints, counts)
|
|
79
|
+
if dependency_failed(converted):
|
|
80
|
+
raise InvalidScoreRequest
|
|
81
|
+
return cast(list[int], converted)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _interior(values: object) -> object:
|
|
85
|
+
return values[1:-1] # type: ignore[index]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _positive_ints(values: object) -> list[int]:
|
|
89
|
+
converted = [_as_int(value) for value in cast(Sequence[object], values)]
|
|
90
|
+
return [value for value in converted if value > 0]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _as_int(value: object) -> int:
|
|
94
|
+
return int(cast(SupportsInt, value))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _bins(
|
|
98
|
+
prob_pred: Sequence[float], prob_true: Sequence[float], weights: list[int]
|
|
99
|
+
) -> list[ReliabilityBin]:
|
|
100
|
+
bins = [
|
|
101
|
+
ReliabilityBin(mean_predicted=float(p), fraction_positive=float(t), count=w)
|
|
102
|
+
for p, t, w in zip(prob_pred, prob_true, weights, strict=True)
|
|
103
|
+
]
|
|
104
|
+
if not all(
|
|
105
|
+
math.isfinite(row.mean_predicted) and math.isfinite(row.fraction_positive) for row in bins
|
|
106
|
+
):
|
|
107
|
+
raise InvalidScoreRequest
|
|
108
|
+
return bins
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _ece(bins: list[ReliabilityBin], total: int) -> float:
|
|
112
|
+
result = sum(b.count / total * abs(b.mean_predicted - b.fraction_positive) for b in bins)
|
|
113
|
+
if not math.isfinite(result):
|
|
114
|
+
raise InvalidScoreRequest
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _call(module: str, name: str, *args: object, **kwargs: object) -> object:
|
|
119
|
+
result = call_dependency(load_callable(module, name), *args, **kwargs)
|
|
120
|
+
if dependency_failed(result):
|
|
121
|
+
raise InvalidScoreRequest
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _curve(
|
|
126
|
+
true_arr: object, score_arr: object, n_bins: int
|
|
127
|
+
) -> tuple[Sequence[float], Sequence[float]]:
|
|
128
|
+
raw = _call(
|
|
129
|
+
"sklearn.calibration",
|
|
130
|
+
"calibration_curve",
|
|
131
|
+
true_arr,
|
|
132
|
+
score_arr,
|
|
133
|
+
n_bins=n_bins,
|
|
134
|
+
strategy="uniform",
|
|
135
|
+
)
|
|
136
|
+
converted = call_dependency(_pair, raw)
|
|
137
|
+
if dependency_failed(converted):
|
|
138
|
+
raise InvalidScoreRequest
|
|
139
|
+
return cast(tuple[Sequence[float], Sequence[float]], converted)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _pair(value: object) -> tuple[Sequence[float], Sequence[float]]:
|
|
143
|
+
first, second = cast(Sequence[Sequence[float]], value)
|
|
144
|
+
return first, second
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _finite_float(value: object) -> float:
|
|
148
|
+
converted = call_dependency(float, value)
|
|
149
|
+
if dependency_failed(converted) or not math.isfinite(cast(float, converted)):
|
|
150
|
+
raise InvalidScoreRequest
|
|
151
|
+
return cast(float, converted)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def calibration_report(
|
|
155
|
+
y_true: Sequence[int], y_score: Sequence[float], *, n_bins: int
|
|
156
|
+
) -> CalibrationReport:
|
|
157
|
+
"""Build the ECE / Brier / reliability report for binary predictions."""
|
|
158
|
+
_validate(y_true, y_score, n_bins)
|
|
159
|
+
true_arr = _call("numpy", "asarray", y_true, dtype=float)
|
|
160
|
+
score_arr = _call("numpy", "asarray", y_score, dtype=float)
|
|
161
|
+
prob_true, prob_pred = _curve(true_arr, score_arr, n_bins)
|
|
162
|
+
bins = _bins(prob_pred, prob_true, _bin_populations(score_arr, n_bins))
|
|
163
|
+
return CalibrationReport(
|
|
164
|
+
ece=_ece(bins, total=len(y_score)),
|
|
165
|
+
brier=_finite_float(_call("sklearn.metrics", "brier_score_loss", true_arr, score_arr)),
|
|
166
|
+
bins=tuple(bins),
|
|
167
|
+
)
|
assay/cli.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Dependency-light entry point with an early historical-command boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from typing import Protocol, cast
|
|
8
|
+
|
|
9
|
+
from assay.errors import AssayError, CliExtraMissing, CommandMovedToAvow, CommandReplaced
|
|
10
|
+
|
|
11
|
+
_EXIT_USAGE = 2
|
|
12
|
+
_MIGRATIONS = {
|
|
13
|
+
"keygen": f"FAIL: {CommandMovedToAvow.code}; use `avow keygen ...`\n",
|
|
14
|
+
"sign": f"FAIL: {CommandMovedToAvow.code}; use `avow sign ...`\n",
|
|
15
|
+
"verify": f"FAIL: {CommandMovedToAvow.code}; use `avow verify ...`\n",
|
|
16
|
+
"verify-ledger": f"FAIL: {CommandMovedToAvow.code}; use `avow ledger verify ...`\n",
|
|
17
|
+
"score": f"FAIL: {CommandReplaced.code}; use `assay measure ...`\n",
|
|
18
|
+
"composite": f"FAIL: {CommandReplaced.code}; use `assay compose ...`\n",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _CommandRunner(Protocol):
|
|
23
|
+
def __call__(self, arguments: tuple[str, ...]) -> int: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _migration_message(arguments: tuple[str, ...]) -> str | None:
|
|
27
|
+
if not arguments:
|
|
28
|
+
return None
|
|
29
|
+
return _MIGRATIONS.get(arguments[0])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run(arguments: tuple[str, ...]) -> int:
|
|
33
|
+
try:
|
|
34
|
+
module = import_module("assay._cli_app")
|
|
35
|
+
except ModuleNotFoundError:
|
|
36
|
+
raise CliExtraMissing from None
|
|
37
|
+
runner = cast(_CommandRunner, module.run)
|
|
38
|
+
return runner(arguments)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _fail(error: AssayError) -> int:
|
|
42
|
+
sys.stderr.write(f"FAIL: {error.code}\n")
|
|
43
|
+
return _EXIT_USAGE
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> int:
|
|
47
|
+
"""Reject migrated commands before loading the scoring command adapter."""
|
|
48
|
+
message = _migration_message(tuple(sys.argv[1:]))
|
|
49
|
+
if message is not None:
|
|
50
|
+
sys.stderr.write(message)
|
|
51
|
+
return _EXIT_USAGE
|
|
52
|
+
try:
|
|
53
|
+
return _run(tuple(sys.argv[1:]))
|
|
54
|
+
except AssayError as error:
|
|
55
|
+
return _fail(error)
|
assay/compose.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""One explicit dispatcher for Assay's closed composition methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import overload
|
|
6
|
+
|
|
7
|
+
from assay.additive import additive
|
|
8
|
+
from assay.contracts import AdditiveRequest, MinimumRequest, ScoreResult, WeightedMeanRequest
|
|
9
|
+
from assay.minimum import minimum
|
|
10
|
+
from assay.weighted_mean import weighted_mean
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@overload
|
|
14
|
+
def compose(request: WeightedMeanRequest) -> ScoreResult: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@overload
|
|
18
|
+
def compose(request: AdditiveRequest) -> ScoreResult: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@overload
|
|
22
|
+
def compose(request: MinimumRequest) -> ScoreResult: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def compose(request: WeightedMeanRequest | AdditiveRequest | MinimumRequest) -> ScoreResult:
|
|
26
|
+
"""Dispatch one validated request without accepting executable formulas."""
|
|
27
|
+
if isinstance(request, WeightedMeanRequest):
|
|
28
|
+
return weighted_mean(request)
|
|
29
|
+
if isinstance(request, AdditiveRequest):
|
|
30
|
+
return additive(request)
|
|
31
|
+
return minimum(request)
|