robustmargin 0.1.1__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.
@@ -0,0 +1,348 @@
1
+ """The grid break-point rule: the smallest tested severity magnitude at which performance fails.
2
+
3
+ ``grid_break_point`` returns the smallest tested severity magnitude at which performance falls
4
+ strictly below a criterion, or an explicit right-censored result when no tested level fails. It
5
+ is a statistic over the levels actually tested, not a fitted estimate. Empty input, non-finite
6
+ values, and negative severities (unless the caller passes ``signed=True``) raise rather than
7
+ returning a silent fallback.
8
+
9
+ This module holds only the grid rule. Model-based dose-response estimation lives in the other
10
+ modules of this package.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from collections.abc import Mapping, Sequence
17
+ from dataclasses import dataclass, field
18
+
19
+ import numpy as np
20
+ from numpy.typing import ArrayLike
21
+ from scipy.stats import binomtest
22
+
23
+ from robustmargin.types import Cell, Censoring, _check_finite_number
24
+
25
+ __all__ = [
26
+ "BaselineRate",
27
+ "ExactRates",
28
+ "GridBreakPoint",
29
+ "grid_break_point",
30
+ "per_cell_clopper_pearson",
31
+ ]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class GridBreakPoint:
36
+ """The result of :func:`grid_break_point`: an observed grid statistic, not a threshold.
37
+
38
+ Attributes
39
+ ----------
40
+ value
41
+ The smallest *tested* severity magnitude whose performance was strictly below the
42
+ criterion, or ``None`` if no tested level failed. It is always one of the tested
43
+ magnitudes, never a fitted or interpolated estimate of where performance crosses the
44
+ criterion, and it carries no confidence interval.
45
+
46
+ Read as a location for the crossing, it is only an **upper bound at grid resolution**.
47
+ If performance were monotone, the crossing would lie somewhere in the half-open gap
48
+ between the largest passing tested magnitude below ``value`` and ``value`` itself. When
49
+ ``value`` equals the smallest tested magnitude (including ``0.0``), no passing level
50
+ was observed below it at all. Monotonicity is neither assumed nor checked.
51
+ max_tested
52
+ The largest tested severity magnitude. Always reported, so a right-censored result can
53
+ be read as "held up to ``max_tested``" rather than as an unqualified pass.
54
+ censoring
55
+ A **point-estimate label for the grid**, not the censoring classification a model-based
56
+ threshold reports. :data:`~robustmargin.types.Censoring.RIGHT` means no tested level's
57
+ performance fell below the criterion (``value is None``).
58
+ :data:`~robustmargin.types.Censoring.NONE` means at least one did. No exact one-sided test
59
+ is applied and no monotonicity is assumed, so ``RIGHT`` here is weaker than a
60
+ statistical lower bound. ``NONE`` does not mean the criterion was bracketed: a zero
61
+ level that already fails gives ``value == 0.0`` with ``NONE``. The grid rule never
62
+ produces ``LEFT``, ``OPEN_UPPER``, or ``OPEN_LOWER``.
63
+ schema_version
64
+ The version of the serialised result schema this object belongs to. Defaults to
65
+ ``"1"``, the only version ``from_dict`` reads; any other version is rejected.
66
+ provenance
67
+ An opaque mapping the caller may attach (for example with :func:`dataclasses.replace`)
68
+ to record where the inputs came from. robustmargin stores it and never interprets it.
69
+ Empty by default.
70
+ """
71
+
72
+ value: float | None
73
+ max_tested: float
74
+ censoring: Censoring
75
+ schema_version: str = "1"
76
+ provenance: Mapping[str, object] = field(default_factory=dict)
77
+
78
+
79
+ def grid_break_point(
80
+ severity: ArrayLike,
81
+ performance: ArrayLike,
82
+ *,
83
+ criterion: float,
84
+ signed: bool = False,
85
+ ) -> GridBreakPoint:
86
+ """Find the smallest tested severity magnitude whose performance fails a criterion.
87
+
88
+ A level fails when its performance is strictly below ``criterion``. A level sitting exactly
89
+ on the criterion passes. This is a statistic over the levels actually tested, not a fitted
90
+ or interpolated estimate: the reported ``value`` is always one of the tested magnitudes, or
91
+ ``None`` when the criterion held everywhere. A ``None`` value paired with
92
+ :data:`~robustmargin.types.Censoring.RIGHT` means performance held up to ``max_tested``. It
93
+ does not mean the system cannot be broken, only that breaking it was not observed within
94
+ the range that was tested.
95
+
96
+ Parameters
97
+ ----------
98
+ severity
99
+ One-dimensional severity levels tested. Must be non-negative unless ``signed=True``.
100
+ performance
101
+ One-dimensional performance values, one per ``severity`` entry, on whatever scale the
102
+ caller has already put them on (for example, a success rate).
103
+ criterion
104
+ The performance level a passing observation must reach or exceed. The comparison is
105
+ strict: ``performance < criterion`` is a failure, ``performance == criterion`` is a
106
+ pass.
107
+ signed
108
+ If ``False`` (the default), a negative ``severity`` entry is treated as caller error and
109
+ raises. If ``True``, ``severity`` may be signed and the rule breaks on magnitude: the
110
+ side that fails at the smallest ``abs(severity)`` determines ``value``.
111
+
112
+ Returns
113
+ -------
114
+ GridBreakPoint
115
+ ``value`` is the smallest tested failing magnitude, or ``None``. ``max_tested`` is the
116
+ largest tested magnitude. ``censoring`` is the grid label described on
117
+ :class:`GridBreakPoint`.
118
+
119
+ Raises
120
+ ------
121
+ ValueError
122
+ If ``severity`` or ``performance`` is empty, is not one-dimensional, or the two have
123
+ mismatched lengths. Also if any value in ``severity``, ``performance``, or
124
+ ``criterion`` is non-finite, or if ``severity`` contains a negative value while
125
+ ``signed=False``.
126
+
127
+ Notes
128
+ -----
129
+ The caller decides what "severity" and "performance" mean on a given axis, for example
130
+ which nuisance factors were pooled to produce ``performance``. This rule performs no
131
+ pooling and fits no model, and it neither assumes nor checks that performance is monotone
132
+ in severity.
133
+
134
+ All inputs are converted to float64 before comparing, so the comparison is exact in
135
+ float64. A float32 performance of ``0.95`` is ``0.949999988...`` and therefore fails a
136
+ criterion of ``0.95``. Integers above ``2**53`` lose precision in that conversion.
137
+ """
138
+ severity_arr = np.asarray(severity, dtype=np.float64)
139
+ performance_arr = np.asarray(performance, dtype=np.float64)
140
+
141
+ if severity_arr.ndim != 1:
142
+ raise ValueError(f"severity must be 1-D, got shape {severity_arr.shape}")
143
+ if performance_arr.ndim != 1:
144
+ raise ValueError(f"performance must be 1-D, got shape {performance_arr.shape}")
145
+ if severity_arr.size == 0:
146
+ raise ValueError("severity and performance must not be empty")
147
+ if severity_arr.size != performance_arr.size:
148
+ raise ValueError(
149
+ "severity and performance must have the same length, got "
150
+ f"{severity_arr.size} and {performance_arr.size}"
151
+ )
152
+ if not np.all(np.isfinite(severity_arr)):
153
+ raise ValueError("severity must be finite (no NaN or inf)")
154
+ if not np.all(np.isfinite(performance_arr)):
155
+ raise ValueError("performance must be finite (no NaN or inf)")
156
+ if not math.isfinite(criterion):
157
+ raise ValueError("criterion must be finite (no NaN or inf)")
158
+ if not signed and bool(np.any(severity_arr < 0.0)):
159
+ raise ValueError("severity must be non-negative unless signed=True")
160
+
161
+ magnitude = np.abs(severity_arr)
162
+ max_tested = float(np.max(magnitude))
163
+
164
+ failing = magnitude[performance_arr < criterion]
165
+ if failing.size == 0:
166
+ return GridBreakPoint(value=None, max_tested=max_tested, censoring=Censoring.RIGHT)
167
+
168
+ return GridBreakPoint(
169
+ value=float(np.min(failing)), max_tested=max_tested, censoring=Censoring.NONE
170
+ )
171
+
172
+
173
+ _VALID_RATE_METHOD = "per_cell_clopper_pearson"
174
+
175
+
176
+ def _check_rate(value: float, *, label: str) -> None:
177
+ _check_finite_number(value, label=label)
178
+ if not (0.0 <= value <= 1.0):
179
+ raise ValueError(f"{label} must lie in [0, 1], got {value!r}")
180
+
181
+
182
+ @dataclass(frozen=True, kw_only=True)
183
+ class BaselineRate:
184
+ """The zero-severity control's exact rate, attached to a :class:`~robustmargin.Threshold` whose
185
+ ``status`` is :data:`~robustmargin.Status.FAILS_AT_BASELINE`.
186
+
187
+ Serialisable (unlike :class:`ExactRates`): a single control cell's rate, labelled with the
188
+ method it came from, is exactly what a ``FAILS_AT_BASELINE`` result should report, and putting
189
+ it in its own field keeps it out of ``Threshold.lo``/``.hi`` (a per-cell exact rate is never
190
+ attached to a threshold).
191
+
192
+ Attributes
193
+ ----------
194
+ severity
195
+ The control's severity. ``0.0`` in every case this package produces.
196
+ successes, trials
197
+ The control cell's observed counts.
198
+ rate
199
+ ``successes / trials``.
200
+ lo, hi
201
+ The **two-sided** exact (Clopper-Pearson) interval at ``level``, as is specified for a
202
+ reported per-cell rate. This is deliberately not the one-sided bound that decided the
203
+ ``FAILS_AT_BASELINE`` classification: that asks "does this cell provably fail the
204
+ target", a one-sided question, while this describes where the control rate lies.
205
+ level
206
+ The confidence of that interval. Carried here so the number is self-describing in a
207
+ stored card rather than requiring a reader to look at the enclosing ``Threshold``.
208
+ method
209
+ Always ``"per_cell_clopper_pearson"``.
210
+ """
211
+
212
+ severity: float
213
+ successes: int
214
+ trials: int
215
+ rate: float
216
+ lo: float
217
+ hi: float
218
+ level: float = 0.95
219
+ method: str = _VALID_RATE_METHOD
220
+
221
+ def __post_init__(self) -> None:
222
+ _check_finite_number(self.severity, label="BaselineRate.severity")
223
+ if self.severity < 0.0:
224
+ raise ValueError(f"BaselineRate.severity must be non-negative, got {self.severity!r}")
225
+ if isinstance(self.successes, bool) or isinstance(self.trials, bool):
226
+ raise ValueError("BaselineRate.successes and .trials must not be bool")
227
+ if self.trials < 1:
228
+ raise ValueError(f"BaselineRate.trials must be >= 1, got {self.trials!r}")
229
+ if not (0 <= self.successes <= self.trials):
230
+ raise ValueError(
231
+ f"BaselineRate.successes ({self.successes!r}) must satisfy "
232
+ f"0 <= successes <= trials ({self.trials!r})"
233
+ )
234
+ _check_rate(self.rate, label="BaselineRate.rate")
235
+ _check_rate(self.lo, label="BaselineRate.lo")
236
+ _check_rate(self.hi, label="BaselineRate.hi")
237
+ _check_finite_number(self.level, label="BaselineRate.level")
238
+ if not (0.0 < self.level < 1.0):
239
+ raise ValueError(f"BaselineRate.level must satisfy 0 < level < 1, got {self.level!r}")
240
+ if not self.lo <= self.rate <= self.hi:
241
+ raise ValueError(
242
+ f"BaselineRate requires lo <= rate <= hi, got lo={self.lo!r}, "
243
+ f"rate={self.rate!r}, hi={self.hi!r}"
244
+ )
245
+ if self.method != _VALID_RATE_METHOD:
246
+ raise ValueError(
247
+ f"BaselineRate.method must be {_VALID_RATE_METHOD!r}, got {self.method!r}"
248
+ )
249
+
250
+
251
+ @dataclass(frozen=True, kw_only=True)
252
+ class ExactRates:
253
+ """Per-cell exact (Clopper-Pearson-style) rate bounds for every cell in a fit, read directly
254
+ from :attr:`robustmargin.Fit.cells`.
255
+
256
+ **Deliberately not serialisable.** This type is excluded from ``report.py``'s tagged classes
257
+ and has no schema entry: ``report.to_dict`` raises ``TypeError`` on it. That is what
258
+ structurally enforces the rule that per-cell rates are never attached to a threshold --
259
+ there is no JSON shape for a caller to smuggle one into.
260
+
261
+ Attributes
262
+ ----------
263
+ severity, rate, lo, hi
264
+ Parallel tuples, one entry per input cell, in the same order.
265
+ level
266
+ The confidence of the two-sided exact interval each ``lo``/``hi`` pair was computed at.
267
+ method
268
+ Always ``"per_cell_clopper_pearson"``.
269
+ """
270
+
271
+ severity: tuple[float, ...]
272
+ rate: tuple[float, ...]
273
+ lo: tuple[float, ...]
274
+ hi: tuple[float, ...]
275
+ level: float = 0.95
276
+ method: str = _VALID_RATE_METHOD
277
+
278
+ def __post_init__(self) -> None:
279
+ object.__setattr__(self, "severity", tuple(self.severity))
280
+ object.__setattr__(self, "rate", tuple(self.rate))
281
+ object.__setattr__(self, "lo", tuple(self.lo))
282
+ object.__setattr__(self, "hi", tuple(self.hi))
283
+ n = len(self.severity)
284
+ if not (len(self.rate) == len(self.lo) == len(self.hi) == n):
285
+ raise ValueError(
286
+ "ExactRates.severity, .rate, .lo and .hi must have equal length, got "
287
+ f"{n}, {len(self.rate)}, {len(self.lo)}, {len(self.hi)}"
288
+ )
289
+ _check_finite_number(self.level, label="ExactRates.level")
290
+ if not (0.0 < self.level < 1.0):
291
+ raise ValueError(f"ExactRates.level must satisfy 0 < level < 1, got {self.level!r}")
292
+ if self.method != _VALID_RATE_METHOD:
293
+ raise ValueError(
294
+ f"ExactRates.method must be {_VALID_RATE_METHOD!r}, got {self.method!r}"
295
+ )
296
+ for severity, rate, lo, hi in zip(self.severity, self.rate, self.lo, self.hi, strict=True):
297
+ _check_finite_number(severity, label="ExactRates.severity entry")
298
+ _check_rate(rate, label="ExactRates.rate entry")
299
+ _check_rate(lo, label="ExactRates.lo entry")
300
+ _check_rate(hi, label="ExactRates.hi entry")
301
+ if not lo <= rate <= hi:
302
+ raise ValueError(
303
+ f"ExactRates requires lo <= rate <= hi per cell, got lo={lo!r}, "
304
+ f"rate={rate!r}, hi={hi!r}"
305
+ )
306
+
307
+
308
+ def per_cell_clopper_pearson(cells: Sequence[Cell], *, level: float = 0.95) -> ExactRates:
309
+ """The exact (Clopper-Pearson) rate and bounds for each cell in ``cells``, independently.
310
+
311
+ Each cell's ``lo``/``hi`` are the **two-sided** exact interval at ``level``, putting
312
+ ``(1 - level) / 2`` in each tail -- a reported per-cell rate is defined as
313
+ ``binomtest(k, n).proportion_ci(confidence_level=..., method="exact")``, which is two-sided.
314
+
315
+ That is deliberately **not** :func:`robustmargin.censoring.exact_one_sided_bound`, which
316
+ answers the different, one-sided question a censoring classification asks ("does this cell
317
+ provably fail the target?"). The two coexist on purpose and must not be conflated: reporting
318
+ the one-sided pair under this function's name would label a roughly ``2 * level - 1`` region
319
+ as ``level``, which is the mislabelling this separation exists to prevent.
320
+
321
+ Parameters
322
+ ----------
323
+ cells
324
+ The cells to compute rates for, typically ``fit.cells`` directly.
325
+ level
326
+ The confidence of the **two-sided** Clopper-Pearson interval, strictly between 0 and 1.
327
+ Defaults to ``0.95``. A reported per-cell rate is defined as
328
+ ``binomtest(k, n).proportion_ci(confidence_level=..., method="exact")``, which is
329
+ two-sided -- *not* the one-sided bound that decides a censoring classification. For
330
+ that, see ``robustmargin.censoring.exact_one_sided_bound``.
331
+
332
+ Returns
333
+ -------
334
+ ExactRates
335
+ Parallel arrays, one entry per cell, in the given order. **Never attach this to a
336
+ threshold**; it is deliberately not serialisable (see :class:`ExactRates`).
337
+ """
338
+ severity = tuple(c.severity for c in cells)
339
+ rate = tuple(c.successes / c.trials for c in cells)
340
+ intervals = [
341
+ binomtest(c.successes, c.trials).proportion_ci(confidence_level=level, method="exact")
342
+ for c in cells
343
+ ]
344
+ lo = tuple(float(i.low) for i in intervals)
345
+ hi = tuple(float(i.high) for i in intervals)
346
+ return ExactRates(
347
+ severity=severity, rate=rate, lo=lo, hi=hi, level=level, method=_VALID_RATE_METHOD
348
+ )