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.
- robustmargin/__init__.py +90 -0
- robustmargin/censoring.py +652 -0
- robustmargin/empirical.py +348 -0
- robustmargin/intervals.py +785 -0
- robustmargin/models.py +2119 -0
- robustmargin/py.typed +0 -0
- robustmargin/ratio.py +941 -0
- robustmargin/report.py +650 -0
- robustmargin/schema/scorecard-v1.json +777 -0
- robustmargin/testing.py +465 -0
- robustmargin/threshold.py +631 -0
- robustmargin/types.py +541 -0
- robustmargin/validation.py +722 -0
- robustmargin-0.1.1.dist-info/METADATA +281 -0
- robustmargin-0.1.1.dist-info/RECORD +18 -0
- robustmargin-0.1.1.dist-info/WHEEL +5 -0
- robustmargin-0.1.1.dist-info/licenses/LICENSE +201 -0
- robustmargin-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|