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/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)