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/metrics.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Deterministic classification metrics — thin wrappers over scikit-learn.
|
|
2
|
+
|
|
3
|
+
Assay reimplements no metric math; it validates inputs, delegates to sklearn, and
|
|
4
|
+
returns an immutable, fully-typed result."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Protocol, SupportsFloat, SupportsInt, cast
|
|
12
|
+
|
|
13
|
+
from assay._optional import call_dependency, dependency_failed, load_callable, load_module
|
|
14
|
+
from assay.errors import InvalidScoreRequest
|
|
15
|
+
from assay.limits import MAX_ITEMS
|
|
16
|
+
|
|
17
|
+
_MIN_CLASSES = 2
|
|
18
|
+
_PRF_RESULT_COUNT = 3
|
|
19
|
+
_CONFUSION_CELL_COUNT = 4
|
|
20
|
+
_METRICS_MODULES = ("numpy", "scipy", "sklearn", "ir_measures", "pydantic_settings")
|
|
21
|
+
|
|
22
|
+
_BINARY_LABELS = (0, 1)
|
|
23
|
+
"""The confusion matrix is pinned to these, in this order, so its orientation can never
|
|
24
|
+
depend on which labels happen to appear in the data."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def require_metrics_extra() -> None:
|
|
28
|
+
"""Refuse optional calculators with one stable, value-free error."""
|
|
29
|
+
for module in _METRICS_MODULES:
|
|
30
|
+
load_module(module)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ConfusionCounts:
|
|
35
|
+
"""The four cells of a binary confusion matrix at one threshold.
|
|
36
|
+
|
|
37
|
+
Named cells, not a bare 2x2 array. ``confusion_matrix(...).ravel()`` returns them in
|
|
38
|
+
the order ``tn, fp, fn, tp``, and reading that tuple in the wrong order is the
|
|
39
|
+
classic silent inversion: it swaps a miss for a false alarm while every total still
|
|
40
|
+
adds up, so nothing downstream can notice."""
|
|
41
|
+
|
|
42
|
+
true_positives: int
|
|
43
|
+
false_positives: int
|
|
44
|
+
true_negatives: int
|
|
45
|
+
false_negatives: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ClassificationScores:
|
|
50
|
+
"""Immutable bundle of binary-classification metrics."""
|
|
51
|
+
|
|
52
|
+
accuracy: float
|
|
53
|
+
precision: float
|
|
54
|
+
recall: float
|
|
55
|
+
f1: float
|
|
56
|
+
pr_auc: float
|
|
57
|
+
roc_auc: float
|
|
58
|
+
counts: ConfusionCounts
|
|
59
|
+
false_negative_rate: float
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _RavelResult(Protocol):
|
|
63
|
+
def ravel(self) -> Sequence[object]: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _validate_shape(y_true: Sequence[int], y_score: Sequence[float]) -> None:
|
|
67
|
+
if len(y_true) != len(y_score) or len(y_true) > MAX_ITEMS:
|
|
68
|
+
raise InvalidScoreRequest
|
|
69
|
+
if not y_true:
|
|
70
|
+
raise InvalidScoreRequest
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _validate(y_true: Sequence[int], y_score: Sequence[float]) -> None:
|
|
74
|
+
_validate_shape(y_true, y_score)
|
|
75
|
+
if not all(math.isfinite(score) for score in y_score):
|
|
76
|
+
raise InvalidScoreRequest
|
|
77
|
+
_require_binary_labels(y_true)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _require_auc_classes(y_true: Sequence[int]) -> None:
|
|
81
|
+
if len(set(y_true)) < _MIN_CLASSES:
|
|
82
|
+
raise InvalidScoreRequest
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _threshold(y_score: Sequence[float], threshold: float) -> list[int]:
|
|
86
|
+
if isinstance(threshold, bool) or not math.isfinite(threshold):
|
|
87
|
+
raise InvalidScoreRequest
|
|
88
|
+
return [1 if s >= threshold else 0 for s in y_score]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _prf(y_true: Sequence[int], y_pred: Sequence[int]) -> tuple[float, float, float]:
|
|
92
|
+
raw = _metric_call(
|
|
93
|
+
"precision_recall_fscore_support",
|
|
94
|
+
y_true,
|
|
95
|
+
y_pred,
|
|
96
|
+
average="binary",
|
|
97
|
+
zero_division=0.0,
|
|
98
|
+
)
|
|
99
|
+
values = call_dependency(_first_three_finite, raw)
|
|
100
|
+
if dependency_failed(values):
|
|
101
|
+
raise InvalidScoreRequest
|
|
102
|
+
return cast(tuple[float, float, float], values)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _first_three_finite(raw: object) -> tuple[float, float, float]:
|
|
106
|
+
values = cast(Sequence[object], raw)
|
|
107
|
+
result = tuple(_as_float(value) for value in values[:_PRF_RESULT_COUNT])
|
|
108
|
+
if len(result) != _PRF_RESULT_COUNT or not all(math.isfinite(value) for value in result):
|
|
109
|
+
raise ValueError
|
|
110
|
+
first, second, third = result
|
|
111
|
+
return first, second, third
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _as_float(value: object) -> float:
|
|
115
|
+
return float(cast(SupportsFloat, value))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _as_int(value: object) -> int:
|
|
119
|
+
return int(cast(SupportsInt, value))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _require_binary_labels(y_true: Sequence[int]) -> None:
|
|
123
|
+
"""Every label must be 0 or 1.
|
|
124
|
+
|
|
125
|
+
This refusal is what makes pinning ``labels=(0, 1)`` safe. sklearn silently DROPS
|
|
126
|
+
every row whose label falls outside that pinning, so a stray 2 would vanish from the
|
|
127
|
+
counts and the four cells would come back looking perfectly healthy, computed over
|
|
128
|
+
fewer examples than the caller handed in."""
|
|
129
|
+
outside = set(y_true) - set(_BINARY_LABELS)
|
|
130
|
+
if outside:
|
|
131
|
+
raise InvalidScoreRequest
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _counts(y_true: Sequence[int], y_pred: Sequence[int]) -> ConfusionCounts:
|
|
135
|
+
raw = _metric_call("confusion_matrix", y_true, y_pred, labels=_BINARY_LABELS)
|
|
136
|
+
cells = call_dependency(_confusion_cells, raw)
|
|
137
|
+
if dependency_failed(cells):
|
|
138
|
+
raise InvalidScoreRequest
|
|
139
|
+
true_negatives, false_positives, false_negatives, true_positives = cast(
|
|
140
|
+
tuple[int, int, int, int], cells
|
|
141
|
+
)
|
|
142
|
+
return ConfusionCounts(
|
|
143
|
+
true_positives=int(true_positives),
|
|
144
|
+
false_positives=int(false_positives),
|
|
145
|
+
true_negatives=int(true_negatives),
|
|
146
|
+
false_negatives=int(false_negatives),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _confusion_cells(raw: object) -> tuple[int, int, int, int]:
|
|
151
|
+
values = cast(_RavelResult, raw).ravel()
|
|
152
|
+
cells = tuple(_as_int(value) for value in values)
|
|
153
|
+
if len(cells) != _CONFUSION_CELL_COUNT or any(value < 0 for value in cells):
|
|
154
|
+
raise ValueError
|
|
155
|
+
return cast(tuple[int, int, int, int], cells)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def confusion_counts(
|
|
159
|
+
y_true: Sequence[int], y_score: Sequence[float], *, threshold: float = 0.5
|
|
160
|
+
) -> ConfusionCounts:
|
|
161
|
+
"""The four confusion cells at ``threshold``, from sklearn's confusion matrix.
|
|
162
|
+
|
|
163
|
+
A rate hides which way a system fails. 200 misses and 2 false alarms produce the same
|
|
164
|
+
accuracy as 2 misses and 200 false alarms, and only the counts tell them apart."""
|
|
165
|
+
_validate(y_true, y_score)
|
|
166
|
+
_require_binary_labels(y_true)
|
|
167
|
+
return _counts(y_true, _threshold(y_score, threshold))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _false_negative_rate(counts: ConfusionCounts) -> float:
|
|
171
|
+
"""Misses over real positives. The denominator cannot be zero: ``_validate`` already
|
|
172
|
+
requires both classes in ``y_true``, so at least one real positive exists."""
|
|
173
|
+
positives = counts.false_negatives + counts.true_positives
|
|
174
|
+
if positives == 0:
|
|
175
|
+
raise InvalidScoreRequest
|
|
176
|
+
return counts.false_negatives / positives
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def false_negative_rate(
|
|
180
|
+
y_true: Sequence[int], y_score: Sequence[float], *, threshold: float = 0.5
|
|
181
|
+
) -> float:
|
|
182
|
+
"""The miss rate: of everything that really was positive, what fraction was called
|
|
183
|
+
negative.
|
|
184
|
+
|
|
185
|
+
Exactly ``1 - recall``, and named anyway. For a screening system the miss is the
|
|
186
|
+
number it is judged on, and nobody reads a 3% miss rate off a recall of 0.97."""
|
|
187
|
+
return _false_negative_rate(confusion_counts(y_true, y_score, threshold=threshold))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _scores(
|
|
191
|
+
y_true: Sequence[int], y_score: Sequence[float], y_pred: Sequence[int]
|
|
192
|
+
) -> ClassificationScores:
|
|
193
|
+
precision, recall, f1 = _prf(y_true, y_pred)
|
|
194
|
+
counts = _counts(y_true, y_pred)
|
|
195
|
+
return ClassificationScores(
|
|
196
|
+
accuracy=_metric_float("accuracy_score", y_true, y_pred),
|
|
197
|
+
precision=precision,
|
|
198
|
+
recall=recall,
|
|
199
|
+
f1=f1,
|
|
200
|
+
pr_auc=_metric_float("average_precision_score", y_true, y_score),
|
|
201
|
+
roc_auc=_metric_float("roc_auc_score", y_true, y_score),
|
|
202
|
+
counts=counts,
|
|
203
|
+
false_negative_rate=_false_negative_rate(counts),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _metric_call(name: str, *args: object, **kwargs: object) -> object:
|
|
208
|
+
result = call_dependency(load_callable("sklearn.metrics", name), *args, **kwargs)
|
|
209
|
+
if dependency_failed(result):
|
|
210
|
+
raise InvalidScoreRequest
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _metric_float(name: str, *args: object) -> float:
|
|
215
|
+
raw = _metric_call(name, *args)
|
|
216
|
+
result = call_dependency(float, raw)
|
|
217
|
+
if dependency_failed(result) or not math.isfinite(cast(float, result)):
|
|
218
|
+
raise InvalidScoreRequest
|
|
219
|
+
return cast(float, result)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def binary_scores(
|
|
223
|
+
y_true: Sequence[int], y_score: Sequence[float], *, threshold: float = 0.5
|
|
224
|
+
) -> ClassificationScores:
|
|
225
|
+
"""Compute accuracy, precision, recall, F1, PR-AUC, ROC-AUC, the four confusion
|
|
226
|
+
counts and the false-negative rate."""
|
|
227
|
+
_validate(y_true, y_score)
|
|
228
|
+
_require_auc_classes(y_true)
|
|
229
|
+
return _scores(y_true, y_score, _threshold(y_score, threshold))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def correctness(
|
|
233
|
+
y_true: Sequence[int], y_score: Sequence[float], *, threshold: float = 0.5
|
|
234
|
+
) -> tuple[float, ...]:
|
|
235
|
+
"""Return a per-example 1.0/0.0 correctness vector (for bootstrapping)."""
|
|
236
|
+
_validate(y_true, y_score)
|
|
237
|
+
y_pred = _threshold(y_score, threshold)
|
|
238
|
+
return tuple(float(int(p == t)) for p, t in zip(y_pred, y_true, strict=True))
|
assay/minimum.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""First-occurrence minimum composition over normalized components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from assay.composite import inputs_hash, interval_or_none
|
|
6
|
+
from assay.contracts import (
|
|
7
|
+
Component,
|
|
8
|
+
ExplainedComponent,
|
|
9
|
+
Interval,
|
|
10
|
+
Method,
|
|
11
|
+
MinimumRequest,
|
|
12
|
+
Operation,
|
|
13
|
+
ScoreResult,
|
|
14
|
+
)
|
|
15
|
+
from assay.normalize import normalize
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _normalized(component: Component, request: MinimumRequest, value: float) -> float:
|
|
19
|
+
return normalize(value, component.scale, request.clamp)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _explain(component: Component, request: MinimumRequest) -> ExplainedComponent:
|
|
23
|
+
candidate = _normalized(component, request, component.value)
|
|
24
|
+
return ExplainedComponent(
|
|
25
|
+
id=component.id,
|
|
26
|
+
raw=component.value,
|
|
27
|
+
normalized=candidate,
|
|
28
|
+
declared_weight=None,
|
|
29
|
+
operation=Operation.ADD,
|
|
30
|
+
coefficient=1.0,
|
|
31
|
+
contribution=candidate,
|
|
32
|
+
contribution_interval=_candidate_interval(component, request),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _bounds(component: Component, request: MinimumRequest) -> tuple[float, float]:
|
|
37
|
+
interval = component.interval
|
|
38
|
+
if interval is None:
|
|
39
|
+
point = _normalized(component, request, component.value)
|
|
40
|
+
return point, point
|
|
41
|
+
first = _normalized(component, request, interval.low)
|
|
42
|
+
second = _normalized(component, request, interval.high)
|
|
43
|
+
return min(first, second), max(first, second)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _candidate_interval(component: Component, request: MinimumRequest) -> Interval | None:
|
|
47
|
+
if component.interval is None:
|
|
48
|
+
return None
|
|
49
|
+
return interval_or_none(*_bounds(component, request))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _propagated_bounds(request: MinimumRequest) -> tuple[float, float]:
|
|
53
|
+
bounds = tuple(_bounds(component, request) for component in request.components)
|
|
54
|
+
return min(low for low, _ in bounds), min(high for _, high in bounds)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _result_interval(request: MinimumRequest) -> Interval | None:
|
|
58
|
+
if not any(component.interval is not None for component in request.components):
|
|
59
|
+
return None
|
|
60
|
+
return interval_or_none(*_propagated_bounds(request))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _result(request: MinimumRequest, rows: tuple[ExplainedComponent, ...]) -> ScoreResult:
|
|
64
|
+
selected = min(rows, key=lambda row: row.contribution)
|
|
65
|
+
return ScoreResult(
|
|
66
|
+
method=Method(id=request.method, version=request.method_version),
|
|
67
|
+
score=selected.contribution,
|
|
68
|
+
interval=_result_interval(request),
|
|
69
|
+
clamp=request.clamp,
|
|
70
|
+
intercept=None,
|
|
71
|
+
weight_total=None,
|
|
72
|
+
components=rows,
|
|
73
|
+
inputs_hash=inputs_hash(request),
|
|
74
|
+
selected_component_id=selected.id,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def minimum(request: MinimumRequest) -> ScoreResult:
|
|
79
|
+
"""Return the first lowest normalized candidate and identify it explicitly."""
|
|
80
|
+
validated = MinimumRequest.model_validate(request)
|
|
81
|
+
rows = tuple(_explain(component, validated) for component in validated.components)
|
|
82
|
+
return _result(validated, rows)
|
assay/models.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Typed request models at the input boundary. Frozen and ``extra="forbid"`` so a
|
|
2
|
+
malformed or ambiguous request is rejected before any computation."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ScoreRequest(BaseModel):
|
|
10
|
+
"""A classification scoring request."""
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
13
|
+
|
|
14
|
+
metric: str
|
|
15
|
+
metric_version: str
|
|
16
|
+
y_true: tuple[int, ...]
|
|
17
|
+
y_score: tuple[float, ...]
|
|
18
|
+
threshold: float = 0.5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SubScoreInput(BaseModel):
|
|
22
|
+
"""One sub-score with its native scale and interval, for a composite."""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
value: float
|
|
28
|
+
low: float
|
|
29
|
+
high: float
|
|
30
|
+
scale_min: float
|
|
31
|
+
scale_max: float
|
|
32
|
+
weight: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CompositeRequest(BaseModel):
|
|
36
|
+
"""A weighted multi-scale composite request."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
39
|
+
|
|
40
|
+
metric: str = "weighted_composite"
|
|
41
|
+
metric_version: str
|
|
42
|
+
subscores: tuple[SubScoreInput, ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RelevanceJudgment(BaseModel):
|
|
46
|
+
"""How relevant one document is to one query.
|
|
47
|
+
|
|
48
|
+
``gain`` 0 means judged and not relevant; any positive gain means relevant, and a
|
|
49
|
+
larger gain means more relevant. Binary judgments are the special case where every
|
|
50
|
+
listed document has gain 1.0."""
|
|
51
|
+
|
|
52
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
53
|
+
|
|
54
|
+
doc_id: str
|
|
55
|
+
gain: float = 1.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RankedQuery(BaseModel):
|
|
59
|
+
"""One query's scoring inputs: the returned order and relevance judgments.
|
|
60
|
+
|
|
61
|
+
``ranked`` is a *position* list — first element is the top hit — not scores. The two
|
|
62
|
+
are deliberately separate: a ranking is judged against the whole judgment set,
|
|
63
|
+
including relevant documents the system never returned."""
|
|
64
|
+
|
|
65
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
66
|
+
|
|
67
|
+
query: str
|
|
68
|
+
judgments: tuple[RelevanceJudgment, ...]
|
|
69
|
+
ranked: tuple[str, ...]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ItemRating(BaseModel):
|
|
73
|
+
"""One item, and the band each of two raters put it in.
|
|
74
|
+
|
|
75
|
+
Item-keyed rather than two loose parallel lists: the id is what makes "the same item
|
|
76
|
+
graded twice" detectable, and a silent duplicate lets one disputed item vote twice."""
|
|
77
|
+
|
|
78
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
79
|
+
|
|
80
|
+
item: str
|
|
81
|
+
rater_a: str
|
|
82
|
+
rater_b: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AgreementRequest(BaseModel):
|
|
86
|
+
"""An inter-rater agreement request over one set of doubly-graded items.
|
|
87
|
+
|
|
88
|
+
``scale`` is ORDERED, weakest band first. The declared order is part of the scoring
|
|
89
|
+
method and result explanation because changing it changes the measurement."""
|
|
90
|
+
|
|
91
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
92
|
+
|
|
93
|
+
metric: str = "agreement"
|
|
94
|
+
metric_version: str
|
|
95
|
+
scale: tuple[str, ...]
|
|
96
|
+
ratings: tuple[ItemRating, ...]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RankingRequest(BaseModel):
|
|
100
|
+
"""A ranked-retrieval scoring request over a whole query set.
|
|
101
|
+
|
|
102
|
+
``k`` left as ``None`` means "use ``AssaySettings.ranking_k``"; whichever value ends
|
|
103
|
+
up applying is method provenance, so a reported precision@k always explains which k
|
|
104
|
+
it used."""
|
|
105
|
+
|
|
106
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
107
|
+
|
|
108
|
+
metric: str = "ranking"
|
|
109
|
+
metric_version: str
|
|
110
|
+
queries: tuple[RankedQuery, ...]
|
|
111
|
+
k: int | None = None
|
assay/normalize.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Pure normalization from a declared native scale to zero through one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Final, NoReturn
|
|
7
|
+
|
|
8
|
+
from assay.contracts import ClampPolicy, Direction, NativeScale
|
|
9
|
+
from assay.errors import ContractCode, ContractValidationError
|
|
10
|
+
|
|
11
|
+
__all__ = ["normalize"]
|
|
12
|
+
|
|
13
|
+
_NORMALIZED_MIN: Final = 0.0
|
|
14
|
+
_NORMALIZED_MAX: Final = 1.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _fail(code: ContractCode) -> NoReturn:
|
|
18
|
+
raise ContractValidationError(code) from None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _finite(value: object) -> float:
|
|
22
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
23
|
+
_fail(ContractCode.INVALID_NUMBER)
|
|
24
|
+
try:
|
|
25
|
+
number = float(value)
|
|
26
|
+
except OverflowError:
|
|
27
|
+
_fail(ContractCode.INVALID_NUMBER)
|
|
28
|
+
if not math.isfinite(number):
|
|
29
|
+
_fail(ContractCode.INVALID_NUMBER)
|
|
30
|
+
return number
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _formula(value: float, scale: NativeScale) -> float:
|
|
34
|
+
width = scale.maximum - scale.minimum
|
|
35
|
+
offset = value - scale.minimum
|
|
36
|
+
if scale.direction is Direction.LOWER_IS_BETTER:
|
|
37
|
+
offset = scale.maximum - value
|
|
38
|
+
if not math.isfinite(width) or not math.isfinite(offset):
|
|
39
|
+
_fail(ContractCode.INVALID_NUMBER)
|
|
40
|
+
result = offset / width
|
|
41
|
+
if not math.isfinite(result):
|
|
42
|
+
_fail(ContractCode.INVALID_NUMBER)
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _canonical_policy(clamp: ClampPolicy) -> ClampPolicy:
|
|
47
|
+
if not isinstance(clamp, ClampPolicy):
|
|
48
|
+
_fail(ContractCode.INVALID_CLAMP_POLICY)
|
|
49
|
+
try:
|
|
50
|
+
return ClampPolicy(str(clamp))
|
|
51
|
+
except ValueError:
|
|
52
|
+
_fail(ContractCode.INVALID_CLAMP_POLICY)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _canonical_zero(value: float) -> float:
|
|
56
|
+
return _NORMALIZED_MIN if value == _NORMALIZED_MIN else value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _apply_policy(result: float, clamp: ClampPolicy) -> float:
|
|
60
|
+
policy = _canonical_policy(clamp)
|
|
61
|
+
if policy is ClampPolicy.CLAMP:
|
|
62
|
+
bounded = min(_NORMALIZED_MAX, max(_NORMALIZED_MIN, result))
|
|
63
|
+
return _canonical_zero(bounded)
|
|
64
|
+
if not _NORMALIZED_MIN <= result <= _NORMALIZED_MAX:
|
|
65
|
+
_fail(ContractCode.OUT_OF_RANGE)
|
|
66
|
+
return _canonical_zero(result)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize(value: float, scale: NativeScale, clamp: ClampPolicy) -> float:
|
|
70
|
+
"""Map one finite native value onto its declared zero-to-one scale."""
|
|
71
|
+
number = _finite(value)
|
|
72
|
+
validated_scale = NativeScale.model_validate(scale)
|
|
73
|
+
result = _formula(number, validated_scale)
|
|
74
|
+
return _apply_policy(result, clamp)
|
assay/py.typed
ADDED
|
File without changes
|