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