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/ranking.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Ranked-retrieval metrics — how good is this ordering, not how good is this score.
|
|
2
|
+
|
|
3
|
+
``metrics.py`` scores ``(y_true, y_score)`` pairs. That shape cannot express retrieval
|
|
4
|
+
quality at all, because it has no notion of *position*: a search engine that returns the
|
|
5
|
+
right product tenth and a search engine that returns it first produce the same numbers.
|
|
6
|
+
This module scores a ``(relevance judgments, ranked list)`` pair instead, which is what a
|
|
7
|
+
search or recommendation system actually emits.
|
|
8
|
+
|
|
9
|
+
The arithmetic is ``trec_eval``'s, reached through ``ir_measures``. trec_eval is the
|
|
10
|
+
reference implementation the IR field validates its own numbers against, so every metric
|
|
11
|
+
here is the field's definition rather than assay's reading of it. Assay contributes two
|
|
12
|
+
things it does not: a Python-native ``(judgments, ranked list)`` contract in place of
|
|
13
|
+
trec_eval's qrels/run file pair, and a refusal for every input whose answer would be
|
|
14
|
+
undefined.
|
|
15
|
+
|
|
16
|
+
*Why not scikit-learn.* ``ndcg_score`` and ``label_ranking_average_precision_score`` are
|
|
17
|
+
multilabel-**classification** metrics. Neither has a notion of a relevant document that
|
|
18
|
+
was never retrieved, so both had to be talked into retrieval semantics at the boundary:
|
|
19
|
+
nDCG by padding the positions the ranker left empty and parking missed documents below
|
|
20
|
+
every filled one, average precision by rescaling LRAP by
|
|
21
|
+
``|relevant retrieved| / |relevant|`` to undo its habit of dividing by the labels it was
|
|
22
|
+
handed — without which, retrieving 1 of 4 relevant documents scored 1.0. Those were
|
|
23
|
+
hand-written semantic corrections wrapped around a mismatched engine, and a subtle error
|
|
24
|
+
in either would have silently corrupted every number downstream. Both are gone; trec_eval
|
|
25
|
+
has these semantics natively.
|
|
26
|
+
|
|
27
|
+
One-line definitions, since none of these terms carry themselves:
|
|
28
|
+
|
|
29
|
+
- **precision@k** — of the top k positions, what fraction held a relevant document.
|
|
30
|
+
- **recall@k** — of everything judged relevant, what fraction reached the top k.
|
|
31
|
+
- **nDCG@k** — how close this ordering is to the best possible one, with each position
|
|
32
|
+
discounted by ``1/log2(rank + 1)`` so a hit at rank 1 counts more than one at rank 10.
|
|
33
|
+
- **MRR** — 1 / (position of the first relevant hit).
|
|
34
|
+
- **average precision** — precision measured at every hit position, averaged over the
|
|
35
|
+
whole relevant set; its mean across queries is MAP.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import math
|
|
41
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
42
|
+
from typing import TYPE_CHECKING, cast
|
|
43
|
+
|
|
44
|
+
from pydantic import BaseModel, ConfigDict
|
|
45
|
+
|
|
46
|
+
from assay._optional import call_dependency, dependency_failed, load_callable, load_object
|
|
47
|
+
from assay.errors import EmptyRelevantSet, InvalidRankingRequest
|
|
48
|
+
from assay.limits import MAX_ITEMS, MAX_RANKING_K, MAX_RELEVANCE_GAIN
|
|
49
|
+
from assay.models import RankedQuery
|
|
50
|
+
from assay.uncertainty import Estimate, mean_interval
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from assay.settings import AssaySettings
|
|
54
|
+
|
|
55
|
+
type Judgments = Mapping[str, float]
|
|
56
|
+
"""Document id -> graded gain. Gain > 0 means relevant; larger means more relevant."""
|
|
57
|
+
|
|
58
|
+
_QUERY = "q"
|
|
59
|
+
"""The query id every evaluation runs under. Assay's contract is one query at a time; the
|
|
60
|
+
id is an internal join key between the qrels and the run and never reaches a result."""
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"Judgments",
|
|
64
|
+
"QueryRanking",
|
|
65
|
+
"RankingReport",
|
|
66
|
+
"average_precision",
|
|
67
|
+
"binary_judgments",
|
|
68
|
+
"f1_at_k",
|
|
69
|
+
"mean_average_precision",
|
|
70
|
+
"mrr",
|
|
71
|
+
"ndcg_at_k",
|
|
72
|
+
"precision_at_k",
|
|
73
|
+
"ranking_report",
|
|
74
|
+
"recall_at_k",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class QueryRanking(BaseModel):
|
|
79
|
+
"""Every metric for one query, so a mean can never hide which query failed."""
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
82
|
+
|
|
83
|
+
query: str
|
|
84
|
+
precision_at_k: float
|
|
85
|
+
recall_at_k: float
|
|
86
|
+
f1_at_k: float
|
|
87
|
+
ndcg_at_k: float
|
|
88
|
+
reciprocal_rank: float
|
|
89
|
+
average_precision: float
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RankingReport(BaseModel):
|
|
93
|
+
"""A whole query set's metrics: every query, the means, and an interval on nDCG.
|
|
94
|
+
|
|
95
|
+
``ndcg_interval`` is a bootstrap confidence interval over the per-query nDCG@k
|
|
96
|
+
values, or an ``Abstention`` when there are fewer queries than the sample floor. It
|
|
97
|
+
is the same uncertainty story the classification face already tells, not a second
|
|
98
|
+
one: a mean nDCG over eight queries is a number the data cannot support, and saying
|
|
99
|
+
so is more useful than printing it."""
|
|
100
|
+
|
|
101
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
102
|
+
|
|
103
|
+
k: int
|
|
104
|
+
n_queries: int
|
|
105
|
+
per_query: tuple[QueryRanking, ...]
|
|
106
|
+
mean_precision_at_k: float
|
|
107
|
+
mean_recall_at_k: float
|
|
108
|
+
mean_f1_at_k: float
|
|
109
|
+
mean_ndcg_at_k: float
|
|
110
|
+
mrr: float
|
|
111
|
+
mean_average_precision: float
|
|
112
|
+
ndcg_interval: Estimate
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def binary_judgments(doc_ids: Iterable[str]) -> dict[str, float]:
|
|
116
|
+
"""A relevant *set* as judgments: every listed document gets gain 1.0."""
|
|
117
|
+
return dict.fromkeys(doc_ids, 1.0)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _require_positive_k(k: int) -> None:
|
|
121
|
+
if isinstance(k, bool) or not 0 < k <= MAX_RANKING_K:
|
|
122
|
+
raise InvalidRankingRequest
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _require_ranked(ranked: Sequence[str]) -> None:
|
|
126
|
+
if not ranked or len(ranked) > MAX_ITEMS:
|
|
127
|
+
raise InvalidRankingRequest("ranked list is empty; nothing was returned to score")
|
|
128
|
+
if len(set(ranked)) != len(ranked):
|
|
129
|
+
raise InvalidRankingRequest("ranked list holds the same document id twice")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _require_graded(relevant: Judgments) -> None:
|
|
133
|
+
"""Gains must be non-negative whole numbers — a relevance grade is a level, not a
|
|
134
|
+
quantity, and trec_eval qrels are integer-graded by definition.
|
|
135
|
+
|
|
136
|
+
A fractional gain is refused rather than rounded. Rounding 0.5 down would move that
|
|
137
|
+
document from relevant to irrelevant and quietly change the answer, and there is no
|
|
138
|
+
reference semantics saying which way it should go."""
|
|
139
|
+
for gain in relevant.values():
|
|
140
|
+
_require_gain(gain)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _require_gain(gain: float) -> None:
|
|
144
|
+
if isinstance(gain, bool):
|
|
145
|
+
raise InvalidRankingRequest
|
|
146
|
+
if not math.isfinite(gain):
|
|
147
|
+
raise InvalidRankingRequest
|
|
148
|
+
if not 0 <= gain <= MAX_RELEVANCE_GAIN:
|
|
149
|
+
raise InvalidRankingRequest
|
|
150
|
+
if gain != int(gain):
|
|
151
|
+
raise InvalidRankingRequest
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _require_relevant(relevant: Judgments) -> None:
|
|
155
|
+
if not any(gain > 0 for gain in relevant.values()):
|
|
156
|
+
raise EmptyRelevantSet("no document is judged relevant for this query")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _validate(relevant: Judgments, ranked: Sequence[str]) -> None:
|
|
160
|
+
if len(relevant) > MAX_ITEMS:
|
|
161
|
+
raise InvalidRankingRequest
|
|
162
|
+
_require_ranked(ranked)
|
|
163
|
+
_require_graded(relevant)
|
|
164
|
+
_require_relevant(relevant)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _validate_at_k(relevant: Judgments, ranked: Sequence[str], k: int) -> None:
|
|
168
|
+
_validate(relevant, ranked)
|
|
169
|
+
_require_positive_k(k)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _qrels(relevant: Judgments) -> dict[str, dict[str, int]]:
|
|
173
|
+
"""Judgments as trec_eval qrels: one query, one integer grade per judged document.
|
|
174
|
+
|
|
175
|
+
Documents the ranker never returned stay in here, which is the whole point — the
|
|
176
|
+
qrels ARE the ideal ranking, so a miss is charged without assay arranging anything."""
|
|
177
|
+
return {_QUERY: {doc: int(gain) for doc, gain in relevant.items()}}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _run(ranked: Sequence[str]) -> dict[str, dict[str, float]]:
|
|
181
|
+
"""The ranked list as a trec_eval run.
|
|
182
|
+
|
|
183
|
+
trec_eval orders by score; assay's contract is an already-ordered list. Strictly
|
|
184
|
+
descending scores reproduce the given order exactly, and no two are equal, so no
|
|
185
|
+
tie-breaking rule can move a number."""
|
|
186
|
+
return {_QUERY: {doc: float(len(ranked) - i) for i, doc in enumerate(ranked)}}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _score(relevant: Judgments, ranked: Sequence[str], measure: object) -> float:
|
|
190
|
+
"""One trec_eval evaluation of one query. Validation is the caller's job."""
|
|
191
|
+
aggregate = load_callable("ir_measures", "calc_aggregate")
|
|
192
|
+
raw = call_dependency(aggregate, [measure], _qrels(relevant), _run(ranked))
|
|
193
|
+
value = call_dependency(_aggregate_value, raw, measure)
|
|
194
|
+
if dependency_failed(raw) or dependency_failed(value):
|
|
195
|
+
raise InvalidRankingRequest
|
|
196
|
+
return _finite_float(value)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _aggregate_value(raw: object, measure: object) -> object:
|
|
200
|
+
return cast(Mapping[object, object], raw)[measure]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _finite_float(value: object) -> float:
|
|
204
|
+
converted = call_dependency(float, value)
|
|
205
|
+
if dependency_failed(converted) or not math.isfinite(cast(float, converted)):
|
|
206
|
+
raise InvalidRankingRequest
|
|
207
|
+
return cast(float, converted)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _cut(name: str, k: int) -> object:
|
|
211
|
+
value = call_dependency(_apply_cut, load_object("ir_measures", name), k)
|
|
212
|
+
if dependency_failed(value):
|
|
213
|
+
raise InvalidRankingRequest
|
|
214
|
+
return value
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _apply_cut(measure: object, k: int) -> object:
|
|
218
|
+
return measure @ k # type: ignore[operator]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def precision_at_k(relevant: Judgments, ranked: Sequence[str], k: int) -> float:
|
|
222
|
+
"""Fraction of the top ``k`` positions that held a relevant document.
|
|
223
|
+
|
|
224
|
+
The denominator is ``k``, not ``min(k, len(ranked))`` — trec_eval's convention, and
|
|
225
|
+
now trec_eval's own arithmetic. A result list shorter than k is a real cost to the
|
|
226
|
+
user, and dividing by the list length would let a ranker score a perfect
|
|
227
|
+
precision@10 by returning one good hit."""
|
|
228
|
+
_validate_at_k(relevant, ranked, k)
|
|
229
|
+
return _score(relevant, ranked, _cut("P", k))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def recall_at_k(relevant: Judgments, ranked: Sequence[str], k: int) -> float:
|
|
233
|
+
"""Fraction of ALL judged-relevant documents that reached the top ``k``.
|
|
234
|
+
|
|
235
|
+
The denominator is the size of the relevant set and never ``k``. Dividing by ``k``
|
|
236
|
+
is the classic recall bug: it silently reports precision under recall's name, so a
|
|
237
|
+
ranker that misses half the relevant documents still looks complete."""
|
|
238
|
+
_validate_at_k(relevant, ranked, k)
|
|
239
|
+
return _score(relevant, ranked, _cut("R", k))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def f1_at_k(relevant: Judgments, ranked: Sequence[str], k: int) -> float:
|
|
243
|
+
"""Harmonic mean of precision@k and recall@k; 0.0 when both are 0.
|
|
244
|
+
|
|
245
|
+
Composed here rather than delegated: neither ir_measures nor ranx ships an F@k
|
|
246
|
+
(ir_measures has ``SetF`` over the whole run, which is a different number), and the
|
|
247
|
+
harmonic mean of two engine results is composition, not a reimplementation."""
|
|
248
|
+
precision = precision_at_k(relevant, ranked, k)
|
|
249
|
+
recall = recall_at_k(relevant, ranked, k)
|
|
250
|
+
if precision + recall == 0:
|
|
251
|
+
return 0.0
|
|
252
|
+
return 2 * precision * recall / (precision + recall)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def ndcg_at_k(relevant: Judgments, ranked: Sequence[str], k: int) -> float:
|
|
256
|
+
"""Normalized discounted cumulative gain at ``k`` — trec_eval's ``ndcg_cut``.
|
|
257
|
+
|
|
258
|
+
Supports graded relevance: a gain of 3 at rank 1 outscores a gain of 1 at rank 1.
|
|
259
|
+
The ideal is taken over every judged-relevant document, including ones the ranker
|
|
260
|
+
never returned, so nDCG cannot be maximised by returning less."""
|
|
261
|
+
_validate_at_k(relevant, ranked, k)
|
|
262
|
+
return _score(relevant, ranked, _cut("nDCG", k))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def mrr(relevant: Judgments, ranked: Sequence[str]) -> float:
|
|
266
|
+
"""Reciprocal rank of the first relevant document; 0.0 if the list holds none."""
|
|
267
|
+
_validate(relevant, ranked)
|
|
268
|
+
return _score(relevant, ranked, load_object("ir_measures", "RR"))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def average_precision(relevant: Judgments, ranked: Sequence[str]) -> float:
|
|
272
|
+
"""Average precision over the whole ranked list — trec_eval's ``map``, one query.
|
|
273
|
+
|
|
274
|
+
The denominator is the number of documents judged relevant *overall*, not the number
|
|
275
|
+
retrieved, so retrieving 3 of 30 relevant documents flawlessly is AP 0.1 and not AP
|
|
276
|
+
1.0. That is the classical definition, and trec_eval implements it directly."""
|
|
277
|
+
_validate(relevant, ranked)
|
|
278
|
+
return _score(relevant, ranked, load_object("ir_measures", "AP"))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _gains(query: RankedQuery) -> dict[str, float]:
|
|
282
|
+
"""A query's judgments as a doc-id map, refusing a document judged twice."""
|
|
283
|
+
gains = {judgment.doc_id: judgment.gain for judgment in query.judgments}
|
|
284
|
+
if len(gains) != len(query.judgments):
|
|
285
|
+
raise InvalidRankingRequest
|
|
286
|
+
return gains
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _require_queries(queries: Sequence[RankedQuery]) -> None:
|
|
290
|
+
if not queries or len(queries) > MAX_ITEMS:
|
|
291
|
+
raise InvalidRankingRequest("query set is empty; there is nothing to average over")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def mean_average_precision(queries: Sequence[RankedQuery]) -> float:
|
|
295
|
+
"""MAP: the mean of every query's average precision."""
|
|
296
|
+
_require_queries(queries)
|
|
297
|
+
return _numpy_mean(tuple(average_precision(_gains(q), q.ranked) for q in queries))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _mean_of(rows: Sequence[QueryRanking], pick: Callable[[QueryRanking], float]) -> float:
|
|
301
|
+
return _numpy_mean(tuple(pick(row) for row in rows))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _numpy_mean(values: Sequence[float]) -> float:
|
|
305
|
+
raw = call_dependency(load_callable("numpy", "mean"), values)
|
|
306
|
+
if dependency_failed(raw):
|
|
307
|
+
raise InvalidRankingRequest
|
|
308
|
+
return _finite_float(raw)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _query_ranking(query: RankedQuery, k: int) -> QueryRanking:
|
|
312
|
+
gains = _gains(query)
|
|
313
|
+
return QueryRanking(
|
|
314
|
+
query=query.query,
|
|
315
|
+
precision_at_k=precision_at_k(gains, query.ranked, k),
|
|
316
|
+
recall_at_k=recall_at_k(gains, query.ranked, k),
|
|
317
|
+
f1_at_k=f1_at_k(gains, query.ranked, k),
|
|
318
|
+
ndcg_at_k=ndcg_at_k(gains, query.ranked, k),
|
|
319
|
+
reciprocal_rank=mrr(gains, query.ranked),
|
|
320
|
+
average_precision=average_precision(gains, query.ranked),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _ndcg_interval(rows: tuple[QueryRanking, ...], settings: AssaySettings) -> Estimate:
|
|
325
|
+
return mean_interval(
|
|
326
|
+
[row.ndcg_at_k for row in rows],
|
|
327
|
+
min_samples=settings.min_samples,
|
|
328
|
+
n_resamples=settings.bootstrap_resamples,
|
|
329
|
+
confidence_level=settings.confidence_level,
|
|
330
|
+
seed=settings.bootstrap_seed,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _report(rows: tuple[QueryRanking, ...], k: int, settings: AssaySettings) -> RankingReport:
|
|
335
|
+
return RankingReport(
|
|
336
|
+
k=k,
|
|
337
|
+
n_queries=len(rows),
|
|
338
|
+
per_query=rows,
|
|
339
|
+
mean_precision_at_k=_mean_of(rows, lambda row: row.precision_at_k),
|
|
340
|
+
mean_recall_at_k=_mean_of(rows, lambda row: row.recall_at_k),
|
|
341
|
+
mean_f1_at_k=_mean_of(rows, lambda row: row.f1_at_k),
|
|
342
|
+
mean_ndcg_at_k=_mean_of(rows, lambda row: row.ndcg_at_k),
|
|
343
|
+
mrr=_mean_of(rows, lambda row: row.reciprocal_rank),
|
|
344
|
+
mean_average_precision=_mean_of(rows, lambda row: row.average_precision),
|
|
345
|
+
ndcg_interval=_ndcg_interval(rows, settings),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def ranking_report(
|
|
350
|
+
queries: Sequence[RankedQuery], *, settings: AssaySettings, k: int | None = None
|
|
351
|
+
) -> RankingReport:
|
|
352
|
+
"""Score a whole query set: every query's metrics, their means, and an interval.
|
|
353
|
+
|
|
354
|
+
``k`` defaults to ``settings.ranking_k`` — nothing here is hardcoded. The per-query
|
|
355
|
+
rows are returned in full alongside the means, because the mean is the number that
|
|
356
|
+
hides a broken query and the rows are the number that names it."""
|
|
357
|
+
resolved_k = settings.ranking_k if k is None else k
|
|
358
|
+
_require_positive_k(resolved_k)
|
|
359
|
+
_require_queries(queries)
|
|
360
|
+
rows = tuple(_query_ranking(query, resolved_k) for query in queries)
|
|
361
|
+
return _report(rows, resolved_k, settings)
|
assay/settings.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Bounded runtime settings loaded lazily from ``ASSAY_*`` environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import cache
|
|
6
|
+
from typing import Annotated, cast
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, create_model
|
|
9
|
+
|
|
10
|
+
from assay._optional import call_dependency, dependency_failed, load_callable, load_object
|
|
11
|
+
from assay.errors import InvalidSettings, MetricsExtraMissing
|
|
12
|
+
from assay.limits import (
|
|
13
|
+
MAX_BOOTSTRAP_RESAMPLES,
|
|
14
|
+
MAX_CALIBRATION_BINS,
|
|
15
|
+
MAX_ITEMS,
|
|
16
|
+
MAX_RANKING_K,
|
|
17
|
+
MAX_SEED,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _reject_bool(value: object) -> object:
|
|
22
|
+
if isinstance(value, bool):
|
|
23
|
+
raise ValueError
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
type _SampleCount = Annotated[int, BeforeValidator(_reject_bool), Field(ge=1, le=MAX_ITEMS)]
|
|
28
|
+
type _Resamples = Annotated[
|
|
29
|
+
int, BeforeValidator(_reject_bool), Field(ge=1, le=MAX_BOOTSTRAP_RESAMPLES)
|
|
30
|
+
]
|
|
31
|
+
type _Confidence = Annotated[
|
|
32
|
+
float, BeforeValidator(_reject_bool), Field(gt=0.0, lt=1.0, allow_inf_nan=False)
|
|
33
|
+
]
|
|
34
|
+
type _Bins = Annotated[int, BeforeValidator(_reject_bool), Field(ge=1, le=MAX_CALIBRATION_BINS)]
|
|
35
|
+
type _Seed = Annotated[int, BeforeValidator(_reject_bool), Field(ge=0, le=MAX_SEED)]
|
|
36
|
+
type _RankingK = Annotated[int, BeforeValidator(_reject_bool), Field(ge=1, le=MAX_RANKING_K)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _create_runtime_model(base: object, config: object) -> type[BaseModel]:
|
|
40
|
+
runtime_base = cast(type[BaseModel], base)
|
|
41
|
+
model = create_model(
|
|
42
|
+
"_RuntimeAssaySettings",
|
|
43
|
+
__base__=runtime_base,
|
|
44
|
+
__config__=cast(ConfigDict, config),
|
|
45
|
+
min_samples=(_SampleCount, _default("min_samples")),
|
|
46
|
+
bootstrap_resamples=(_Resamples, _default("bootstrap_resamples")),
|
|
47
|
+
confidence_level=(_Confidence, _default("confidence_level")),
|
|
48
|
+
ece_bins=(_Bins, _default("ece_bins")),
|
|
49
|
+
bootstrap_seed=(_Seed, _default("bootstrap_seed")),
|
|
50
|
+
ranking_k=(_RankingK, _default("ranking_k")),
|
|
51
|
+
)
|
|
52
|
+
return model
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@cache
|
|
56
|
+
def _runtime_model() -> type[BaseModel]:
|
|
57
|
+
base = load_object("pydantic_settings", "BaseSettings")
|
|
58
|
+
config_factory = load_callable("pydantic_settings", "SettingsConfigDict")
|
|
59
|
+
config = call_dependency(config_factory, env_prefix="ASSAY_", frozen=True, extra="forbid")
|
|
60
|
+
model = call_dependency(_create_runtime_model, base, config)
|
|
61
|
+
if dependency_failed(config) or dependency_failed(model):
|
|
62
|
+
raise MetricsExtraMissing
|
|
63
|
+
return cast(type[BaseModel], model)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _settings_values(data: dict[str, object]) -> dict[str, object]:
|
|
67
|
+
loaded = call_dependency(_runtime_model(), **data)
|
|
68
|
+
if dependency_failed(loaded):
|
|
69
|
+
raise InvalidSettings
|
|
70
|
+
return cast(BaseModel, loaded).model_dump()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AssaySettings(BaseModel):
|
|
74
|
+
"""Finite scoring controls, sourced from direct values or ``ASSAY_*`` variables."""
|
|
75
|
+
|
|
76
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
77
|
+
|
|
78
|
+
min_samples: _SampleCount = 30
|
|
79
|
+
bootstrap_resamples: _Resamples = 9999
|
|
80
|
+
confidence_level: _Confidence = 0.95
|
|
81
|
+
ece_bins: _Bins = 15
|
|
82
|
+
bootstrap_seed: _Seed = 12345
|
|
83
|
+
ranking_k: _RankingK = 10
|
|
84
|
+
|
|
85
|
+
def __init__(self, **data: object) -> None:
|
|
86
|
+
super().__init__(**_settings_values(data))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _default(name: str) -> object:
|
|
90
|
+
return AssaySettings.model_fields[name].default
|
assay/uncertainty.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Uncertainty with an honesty floor.
|
|
2
|
+
|
|
3
|
+
Above ``min_samples`` we return a percentile bootstrap confidence interval
|
|
4
|
+
(``scipy.stats.bootstrap``, fixed seed → reproducible). Below the floor we return
|
|
5
|
+
an ``Abstention`` — never a point estimate the data cannot support. ``percentile``
|
|
6
|
+
is chosen over ``BCa`` because it is robust and fully deterministic with a fixed
|
|
7
|
+
seed (BCa can fail on low-variance samples)."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import warnings
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Literal, Protocol, cast
|
|
16
|
+
|
|
17
|
+
from assay._optional import call_dependency, dependency_failed, load_callable
|
|
18
|
+
from assay.errors import InvalidScoreRequest
|
|
19
|
+
from assay.limits import (
|
|
20
|
+
MAX_BOOTSTRAP_BATCH_CELLS,
|
|
21
|
+
MAX_BOOTSTRAP_RESAMPLES,
|
|
22
|
+
MAX_BOOTSTRAP_WORK_CELLS,
|
|
23
|
+
MAX_ITEMS,
|
|
24
|
+
MAX_SEED,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Interval:
|
|
30
|
+
"""A point estimate with a bootstrap confidence interval."""
|
|
31
|
+
|
|
32
|
+
kind: Literal["interval"]
|
|
33
|
+
point: float
|
|
34
|
+
low: float
|
|
35
|
+
high: float
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Abstention:
|
|
40
|
+
"""Refusal to emit a point estimate below the sample-size floor."""
|
|
41
|
+
|
|
42
|
+
kind: Literal["abstention"]
|
|
43
|
+
reason: str
|
|
44
|
+
n_samples: int
|
|
45
|
+
min_samples: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
type Estimate = Interval | Abstention
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class _BootstrapSettings:
|
|
53
|
+
"""Inputs to the internal interval calculation."""
|
|
54
|
+
|
|
55
|
+
min_samples: int
|
|
56
|
+
n_resamples: int
|
|
57
|
+
confidence_level: float
|
|
58
|
+
seed: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _ConfidenceBounds(Protocol):
|
|
62
|
+
low: object
|
|
63
|
+
high: object
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _BootstrapResult(Protocol):
|
|
67
|
+
confidence_interval: _ConfidenceBounds
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _bootstrap_mean(data: Sequence[float], settings: _BootstrapSettings) -> tuple[float, float]:
|
|
71
|
+
bounds = call_dependency(_confidence_bounds, _bootstrap_result(data, settings))
|
|
72
|
+
if dependency_failed(bounds):
|
|
73
|
+
raise InvalidScoreRequest
|
|
74
|
+
low, high = cast(tuple[object, object], bounds)
|
|
75
|
+
return _finite_float(low), _finite_float(high)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _bootstrap_result(data: Sequence[float], settings: _BootstrapSettings) -> object:
|
|
79
|
+
with warnings.catch_warnings():
|
|
80
|
+
warnings.simplefilter("ignore", RuntimeWarning)
|
|
81
|
+
return _call(
|
|
82
|
+
"scipy.stats",
|
|
83
|
+
"bootstrap",
|
|
84
|
+
(data,),
|
|
85
|
+
load_callable("numpy", "mean"),
|
|
86
|
+
n_resamples=settings.n_resamples,
|
|
87
|
+
confidence_level=settings.confidence_level,
|
|
88
|
+
method="percentile",
|
|
89
|
+
rng=settings.seed,
|
|
90
|
+
batch=_batch_size(len(data), settings.n_resamples),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _batch_size(sample_count: int, resamples: int) -> int:
|
|
95
|
+
cells_per_batch = max(1, MAX_BOOTSTRAP_BATCH_CELLS // max(1, sample_count))
|
|
96
|
+
return min(resamples, cells_per_batch)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _confidence_bounds(result: object) -> tuple[object, object]:
|
|
100
|
+
interval = cast(_BootstrapResult, result).confidence_interval
|
|
101
|
+
return interval.low, interval.high
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _percentile_interval(data: Sequence[float], settings: _BootstrapSettings) -> Interval:
|
|
105
|
+
if len(set(data)) == 1:
|
|
106
|
+
point = data[0]
|
|
107
|
+
return Interval(kind="interval", point=point, low=point, high=point)
|
|
108
|
+
low, high = _bootstrap_mean(data, settings)
|
|
109
|
+
return Interval(
|
|
110
|
+
kind="interval",
|
|
111
|
+
point=_finite_float(_call("numpy", "mean", data)),
|
|
112
|
+
low=low,
|
|
113
|
+
high=high,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _estimate(samples: Sequence[float], settings: _BootstrapSettings) -> Estimate:
|
|
118
|
+
count = len(samples)
|
|
119
|
+
if count < settings.min_samples:
|
|
120
|
+
return Abstention("abstention", "sample count below floor", count, settings.min_samples)
|
|
121
|
+
return _percentile_interval(samples, settings)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _validate_settings(settings: _BootstrapSettings) -> None:
|
|
125
|
+
_validate_positive_count(settings.min_samples, MAX_ITEMS)
|
|
126
|
+
_validate_positive_count(settings.n_resamples, MAX_BOOTSTRAP_RESAMPLES)
|
|
127
|
+
if isinstance(settings.seed, bool) or not 0 <= settings.seed <= MAX_SEED:
|
|
128
|
+
raise InvalidScoreRequest
|
|
129
|
+
_validate_confidence(settings.confidence_level)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _validate_positive_count(value: int, maximum: int) -> None:
|
|
133
|
+
if isinstance(value, bool) or not 0 < value <= maximum:
|
|
134
|
+
raise InvalidScoreRequest
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _validate_confidence(value: float) -> None:
|
|
138
|
+
if not _is_finite_number(value) or not 0.0 < value < 1.0:
|
|
139
|
+
raise InvalidScoreRequest
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_finite_number(value: object) -> bool:
|
|
143
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
144
|
+
return False
|
|
145
|
+
try:
|
|
146
|
+
return math.isfinite(value)
|
|
147
|
+
except OverflowError:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _validate_samples(samples: Sequence[float]) -> None:
|
|
152
|
+
if len(samples) > MAX_ITEMS or not all(_is_finite_number(sample) for sample in samples):
|
|
153
|
+
raise InvalidScoreRequest
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _validate(samples: Sequence[float], settings: _BootstrapSettings) -> None:
|
|
157
|
+
_validate_settings(settings)
|
|
158
|
+
_validate_samples(samples)
|
|
159
|
+
if len(samples) * settings.n_resamples > MAX_BOOTSTRAP_WORK_CELLS:
|
|
160
|
+
raise InvalidScoreRequest
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _call(module: str, name: str, *args: object, **kwargs: object) -> object:
|
|
164
|
+
result = call_dependency(load_callable(module, name), *args, **kwargs)
|
|
165
|
+
if dependency_failed(result):
|
|
166
|
+
raise InvalidScoreRequest
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _finite_float(value: object) -> float:
|
|
171
|
+
converted = call_dependency(float, value)
|
|
172
|
+
if dependency_failed(converted) or not math.isfinite(cast(float, converted)):
|
|
173
|
+
raise InvalidScoreRequest
|
|
174
|
+
return cast(float, converted)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def mean_interval(
|
|
178
|
+
samples: Sequence[float],
|
|
179
|
+
*,
|
|
180
|
+
min_samples: int,
|
|
181
|
+
n_resamples: int,
|
|
182
|
+
confidence_level: float,
|
|
183
|
+
seed: int,
|
|
184
|
+
) -> Estimate:
|
|
185
|
+
"""Bootstrap CI of the mean, or abstain below ``min_samples``."""
|
|
186
|
+
settings = _BootstrapSettings(min_samples, n_resamples, confidence_level, seed)
|
|
187
|
+
_validate(samples, settings)
|
|
188
|
+
load_callable("numpy", "mean")
|
|
189
|
+
load_callable("scipy.stats", "bootstrap")
|
|
190
|
+
return _estimate(samples, settings)
|