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
robustmargin/types.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""robustmargin's shared vocabulary for severity and outcome data.
|
|
2
|
+
|
|
3
|
+
``Censoring`` is the model-based and grid-rule censoring classification (documented on the
|
|
4
|
+
class itself). Everything else here is new to robustmargin: ``Axis`` describes the
|
|
5
|
+
severity axis a series of observations was measured on; ``Observations`` holds the raw
|
|
6
|
+
aggregated-or-per-observation data a fit is built from; ``Definition`` names which of three
|
|
7
|
+
target-performance definitions a threshold solves for; ``Cell`` is one pooled row of counts at
|
|
8
|
+
a single severity; ``Status`` and ``IntervalShape`` are the enums results report through.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Axis",
|
|
20
|
+
"Cell",
|
|
21
|
+
"Censoring",
|
|
22
|
+
"Definition",
|
|
23
|
+
"IntervalShape",
|
|
24
|
+
"JSONValue",
|
|
25
|
+
"Observations",
|
|
26
|
+
"Status",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# A JSON value as this package writes and reads it: the recursive union `report.py` validates
|
|
30
|
+
# provenance mappings against and the schema-loading code returns. Not itself validated here --
|
|
31
|
+
# `report.to_dict` is what actually walks a provenance mapping and rejects anything outside
|
|
32
|
+
# this union (str keys only, no tuple, no set, no NaN).
|
|
33
|
+
JSONValue = None | bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Censoring(StrEnum):
|
|
37
|
+
"""How a result's tested range relates to a criterion.
|
|
38
|
+
|
|
39
|
+
Every member is a ``str`` subclass whose value equals its member name (``Censoring.RIGHT
|
|
40
|
+
== "RIGHT"``). That value is the literal string a serialised result writes for this field,
|
|
41
|
+
not an incidental implementation detail.
|
|
42
|
+
|
|
43
|
+
**Model-based thresholds** use the full classification. It assumes performance is
|
|
44
|
+
monotone in severity, and a result that uses it says so. ``RIGHT`` and ``LEFT`` are decided
|
|
45
|
+
by exact one-sided tests at the edge of the tested range, not by point estimates.
|
|
46
|
+
|
|
47
|
+
**The grid rule** (:func:`robustmargin.grid_break_point`) uses only ``NONE`` and ``RIGHT``, and
|
|
48
|
+
only as point-estimate labels, with no exact test and no monotonicity assumption. Their
|
|
49
|
+
meaning for a grid result is documented on :class:`robustmargin.GridBreakPoint` and is weaker
|
|
50
|
+
than the definitions below.
|
|
51
|
+
|
|
52
|
+
Attributes
|
|
53
|
+
----------
|
|
54
|
+
NONE
|
|
55
|
+
Model-based threshold: none of the conditions below applies, so a value is reported
|
|
56
|
+
with both interval sides closed. Grid result: at least one tested level failed. This
|
|
57
|
+
does not imply the criterion was bracketed.
|
|
58
|
+
RIGHT
|
|
59
|
+
Model-based threshold: the exact one-sided bound at the highest tested level shows the
|
|
60
|
+
criterion still holds there. No value is reported. The top of the tested range is
|
|
61
|
+
reported as a one-sided lower bound ("held up to the highest level tested") and is
|
|
62
|
+
never phrased as "does not fail" or "unbreakable". Grid result: no tested level's
|
|
63
|
+
performance fell below the criterion.
|
|
64
|
+
LEFT
|
|
65
|
+
Model-based threshold: the exact one-sided bound at the lowest nonzero tested level
|
|
66
|
+
shows it already fails, while the control passes. No value is reported. The lowest
|
|
67
|
+
nonzero level is reported as a one-sided upper bound. Never produced by the grid rule.
|
|
68
|
+
OPEN_UPPER
|
|
69
|
+
Model-based threshold: the value lies inside the tested range, but the upper side of
|
|
70
|
+
its profile interval does not close within the search limit. The value and the closed
|
|
71
|
+
lower side are still reported. Never produced by the grid rule.
|
|
72
|
+
OPEN_LOWER
|
|
73
|
+
The mirror of ``OPEN_UPPER``: the lower side of the profile interval does not close
|
|
74
|
+
within the search limit. The value and the closed upper side are still reported. Never
|
|
75
|
+
produced by the grid rule.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
NONE = "NONE"
|
|
79
|
+
RIGHT = "RIGHT"
|
|
80
|
+
LEFT = "LEFT"
|
|
81
|
+
OPEN_UPPER = "OPEN_UPPER"
|
|
82
|
+
OPEN_LOWER = "OPEN_LOWER"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Status(StrEnum):
|
|
86
|
+
"""How a fit or a threshold derived from it stands, following ``Censoring``: each member's
|
|
87
|
+
value equals its name.
|
|
88
|
+
|
|
89
|
+
Attributes
|
|
90
|
+
----------
|
|
91
|
+
OK
|
|
92
|
+
The fit converged and the threshold, if any, was solved for.
|
|
93
|
+
SEPARATION
|
|
94
|
+
The data are completely or quasi-completely separated: some or all outcomes are
|
|
95
|
+
perfectly predicted by severity, so the likelihood has no finite maximum on at least
|
|
96
|
+
one parameter. No model-based interval is produced; a threshold built from this fit
|
|
97
|
+
falls back to the exact censoring bound.
|
|
98
|
+
NOT_CONVERGED
|
|
99
|
+
The optimizer did not converge, or converged to a point where the Hessian is not
|
|
100
|
+
positive-definite. Same fallback as ``SEPARATION``.
|
|
101
|
+
UNREACHABLE
|
|
102
|
+
The target performance implied by a threshold's ``Definition`` lies outside the fitted
|
|
103
|
+
curve's range (at or beyond one of the asymptotes), so no severity solves for it.
|
|
104
|
+
FAILS_AT_BASELINE
|
|
105
|
+
The zero-severity control's exact one-sided bound already fails the criterion: the
|
|
106
|
+
threshold does not exist because the system was never passing to begin with.
|
|
107
|
+
CONTROL_INCOMPATIBLE
|
|
108
|
+
The upper asymptote is fixed (commonly at 1) but the zero-severity control has
|
|
109
|
+
failures, which drives the likelihood to zero for that fixed value. Refit with
|
|
110
|
+
``upper="estimate"``.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
OK = "OK"
|
|
114
|
+
SEPARATION = "SEPARATION"
|
|
115
|
+
NOT_CONVERGED = "NOT_CONVERGED"
|
|
116
|
+
UNREACHABLE = "UNREACHABLE"
|
|
117
|
+
FAILS_AT_BASELINE = "FAILS_AT_BASELINE"
|
|
118
|
+
CONTROL_INCOMPATIBLE = "CONTROL_INCOMPATIBLE"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class IntervalShape(StrEnum):
|
|
122
|
+
"""The geometry of a ratio's confidence interval on the real line, following ``Censoring``:
|
|
123
|
+
each member's value equals its name.
|
|
124
|
+
|
|
125
|
+
A Fieller interval for the ratio of two normal-ish quantities is not always a bounded
|
|
126
|
+
interval; which shape it takes depends on how much relative uncertainty the denominator
|
|
127
|
+
threshold carries.
|
|
128
|
+
|
|
129
|
+
Attributes
|
|
130
|
+
----------
|
|
131
|
+
BOUNDED
|
|
132
|
+
The usual case: ``[lo, hi]``, a single closed interval. Also covers a Fieller set bounded
|
|
133
|
+
above but not below on its own: intersected with the positive parameter space that is
|
|
134
|
+
``[0, hi]``, and ``0`` is a true lower bound for a severity ratio, so ``BOUNDED`` --
|
|
135
|
+
never ``HALF_OPEN`` -- is reported (the upper-only geometry stays ``BOUNDED``).
|
|
136
|
+
``HALF_OPEN`` is therefore always lower-bounded, never upper-bounded, and that asymmetry
|
|
137
|
+
is deliberate.
|
|
138
|
+
UNBOUNDED
|
|
139
|
+
The confidence set, intersected with the positive parameter space, is the whole of
|
|
140
|
+
``(0, inf)``; ``lo`` and ``hi`` are both ``None``.
|
|
141
|
+
EXCLUSIVE
|
|
142
|
+
Two disjoint rays, ``(-inf, lo] union [hi, inf)``, with both ``lo`` and ``hi`` on the
|
|
143
|
+
same side of zero (both negative is the ``UNBOUNDED`` case; a positive ``lo`` never
|
|
144
|
+
arises under ``dependence="independent"``). **Unreachable in v0.1**: under
|
|
145
|
+
``dependence="independent"`` the covariance term is 0, which forces opposite-signed roots
|
|
146
|
+
whenever this branch's other conditions hold, so the real result is always ``HALF_OPEN``
|
|
147
|
+
instead. The member and :class:`~robustmargin.Ratio`'s invariants for it stay enforced
|
|
148
|
+
against v0.2's ``dependence="paired"``, which can make the covariance term nonzero and
|
|
149
|
+
reach this shape for the first time.
|
|
150
|
+
HALF_OPEN
|
|
151
|
+
One ray, ``[lo, inf)``: ``lo`` is set, ``hi`` is ``None``. This is what a two-ray
|
|
152
|
+
Fieller set with ``K > 0`` is once intersected with the positive parameter space. It arises
|
|
153
|
+
whenever the denominator threshold is poorly determined (``z^2*Vb > theta_b^2``) while
|
|
154
|
+
the numerator is not -- an open region of the parameter space, not a measure-zero
|
|
155
|
+
boundary.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
BOUNDED = "BOUNDED"
|
|
159
|
+
UNBOUNDED = "UNBOUNDED"
|
|
160
|
+
EXCLUSIVE = "EXCLUSIVE"
|
|
161
|
+
HALF_OPEN = "HALF_OPEN"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_VALID_SCALES = frozenset({"log", "linear"})
|
|
165
|
+
_VALID_DIRECTIONS = frozenset({"decreasing", "increasing"})
|
|
166
|
+
_VALID_DEFINITION_KINDS = frozenset({"absolute", "baseline_fraction", "relative"})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _check_finite_number(value: float, *, label: str) -> None:
|
|
170
|
+
"""Reject a bool masquerading as a number, and reject a non-finite value (``nan``,
|
|
171
|
+
``inf`` or ``-inf``).
|
|
172
|
+
|
|
173
|
+
Every numeric field across the package that must be a genuine finite number --
|
|
174
|
+
``Definition.value``, ``Cell.severity``, ``Observations.severity``, ``Fit.log_likelihood``,
|
|
175
|
+
``Threshold``'s ``value``/``lo``/``hi``/``level``, ``Ratio``'s ``estimate``/``lo``/``hi``/
|
|
176
|
+
``level``, and every ``Covariance.matrix`` entry -- routes its bool/finite check through
|
|
177
|
+
this one function, so the rule cannot silently drift between them.
|
|
178
|
+
"""
|
|
179
|
+
if isinstance(value, bool):
|
|
180
|
+
raise ValueError(f"{label} must not be a bool, got {value!r}")
|
|
181
|
+
if not math.isfinite(value):
|
|
182
|
+
raise ValueError(f"{label} must be finite, got {value!r}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _as_whole_int(value: float, *, label: str) -> int:
|
|
186
|
+
"""Coerce a whole-number ``int`` or ``float`` (for example ``200`` or ``200.0``) to a
|
|
187
|
+
genuine ``int``. A bool, a non-finite value, or a non-whole float (``2.5``) raises
|
|
188
|
+
``ValueError``.
|
|
189
|
+
"""
|
|
190
|
+
_check_finite_number(value, label=label)
|
|
191
|
+
as_int = int(value)
|
|
192
|
+
if as_int != value:
|
|
193
|
+
raise ValueError(f"{label} must be a whole number, got {value!r}")
|
|
194
|
+
return as_int
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True, kw_only=True)
|
|
198
|
+
class Axis:
|
|
199
|
+
"""The severity axis a series of observations was measured on.
|
|
200
|
+
|
|
201
|
+
Attributes
|
|
202
|
+
----------
|
|
203
|
+
name
|
|
204
|
+
A short, non-empty, domain-neutral name for the axis (for example ``"noise"``).
|
|
205
|
+
unit
|
|
206
|
+
The unit severity is expressed in (for example ``"m"``). Non-empty. A ratio between two
|
|
207
|
+
thresholds is allowed only when both axes match on ``name`` and ``unit``.
|
|
208
|
+
scale
|
|
209
|
+
Either ``"log"`` or ``"linear"``: which scale the dose-response model is fit on.
|
|
210
|
+
citation
|
|
211
|
+
An optional free-text citation for where the axis or its unit convention comes from (for
|
|
212
|
+
example a datasheet reference). ``None`` by default; if given, must be non-empty.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
name: str
|
|
216
|
+
unit: str
|
|
217
|
+
scale: str
|
|
218
|
+
citation: str | None = None
|
|
219
|
+
|
|
220
|
+
def __post_init__(self) -> None:
|
|
221
|
+
if not self.name.strip():
|
|
222
|
+
raise ValueError("Axis.name must be non-empty (after stripping whitespace)")
|
|
223
|
+
if not self.unit.strip():
|
|
224
|
+
raise ValueError("Axis.unit must be non-empty (after stripping whitespace)")
|
|
225
|
+
if self.scale not in _VALID_SCALES:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"Axis.scale must be one of {sorted(_VALID_SCALES)}, got {self.scale!r}"
|
|
228
|
+
)
|
|
229
|
+
if self.citation is not None and not self.citation.strip():
|
|
230
|
+
raise ValueError("Axis.citation, if given, must be non-empty")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(frozen=True, kw_only=True)
|
|
234
|
+
class Definition:
|
|
235
|
+
"""Which target performance a threshold solves for.
|
|
236
|
+
|
|
237
|
+
Three definitions are in play, and they coincide only when the fitted curve's asymptotes
|
|
238
|
+
are exactly 1 and 0:
|
|
239
|
+
|
|
240
|
+
- ``absolute(a)``: performance crosses a fixed level ``a``.
|
|
241
|
+
Whether ``a`` is reachable given the fitted asymptotes is decided at threshold-solving
|
|
242
|
+
time (:data:`Status.UNREACHABLE`), not at construction time, so any finite ``a`` --
|
|
243
|
+
including one that looks out of range, such as 1.5 -- constructs successfully.
|
|
244
|
+
- ``baseline_fraction(p)``: performance falls to ``p`` times the performance at zero
|
|
245
|
+
severity. Requires ``0 < p < 1``.
|
|
246
|
+
- ``relative(p)``: a fraction ``p`` of the fitted dynamic range (upper minus lower
|
|
247
|
+
asymptote) is lost (R ``drc``'s ``type = "relative"``). Requires ``0 < p < 1``.
|
|
248
|
+
|
|
249
|
+
Attributes
|
|
250
|
+
----------
|
|
251
|
+
kind
|
|
252
|
+
One of ``"absolute"``, ``"baseline_fraction"``, ``"relative"``.
|
|
253
|
+
value
|
|
254
|
+
The threshold parameter: ``a`` for ``absolute``, ``p`` for the other two. Must be
|
|
255
|
+
finite.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
kind: str
|
|
259
|
+
value: float
|
|
260
|
+
|
|
261
|
+
def __post_init__(self) -> None:
|
|
262
|
+
if self.kind not in _VALID_DEFINITION_KINDS:
|
|
263
|
+
raise ValueError(
|
|
264
|
+
f"Definition.kind must be one of {sorted(_VALID_DEFINITION_KINDS)}, "
|
|
265
|
+
f"got {self.kind!r}"
|
|
266
|
+
)
|
|
267
|
+
_check_finite_number(self.value, label="Definition.value")
|
|
268
|
+
if self.kind in ("baseline_fraction", "relative") and not (0.0 < self.value < 1.0):
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"Definition.value for kind={self.kind!r} must satisfy 0 < value < 1, "
|
|
271
|
+
f"got {self.value!r}"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
@classmethod
|
|
275
|
+
def absolute(cls, a: float, /) -> Definition:
|
|
276
|
+
"""Build an ``absolute(a)`` definition: performance crosses the fixed level ``a``."""
|
|
277
|
+
return cls(kind="absolute", value=a)
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def baseline_fraction(cls, p: float, /) -> Definition:
|
|
281
|
+
"""Build a ``baseline_fraction(p)`` definition: performance falls to ``p`` times the
|
|
282
|
+
zero-severity performance. Requires ``0 < p < 1``."""
|
|
283
|
+
return cls(kind="baseline_fraction", value=p)
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def relative(cls, p: float, /) -> Definition:
|
|
287
|
+
"""Build a ``relative(p)`` definition: a fraction ``p`` of the fitted dynamic range is
|
|
288
|
+
lost. Requires ``0 < p < 1``."""
|
|
289
|
+
return cls(kind="relative", value=p)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _check_counts(
|
|
293
|
+
*, severity: float, successes: float, trials: float, label: str
|
|
294
|
+
) -> tuple[int, int]:
|
|
295
|
+
"""Validate one ``(severity, successes, trials)`` row and return ``successes``/``trials``
|
|
296
|
+
coerced to ``int``.
|
|
297
|
+
|
|
298
|
+
``successes`` and ``trials`` each accept a whole-number ``float`` (for example ``200.0``)
|
|
299
|
+
as well as a genuine ``int``: a JSON-Schema "integer" cannot tell ``200.0`` from ``200``, so
|
|
300
|
+
both direct construction and ``from_dict`` must accept it. A bool or a
|
|
301
|
+
non-whole float (``2.5``) still raises. The coerced ``int`` values are what gets stored on
|
|
302
|
+
the dataclass, never the original float, so ``type(cell.successes) is int`` always holds.
|
|
303
|
+
"""
|
|
304
|
+
_check_finite_number(severity, label=f"{label}.severity")
|
|
305
|
+
successes_int = _as_whole_int(successes, label=f"{label}.successes")
|
|
306
|
+
trials_int = _as_whole_int(trials, label=f"{label}.trials")
|
|
307
|
+
if severity < 0.0:
|
|
308
|
+
raise ValueError(f"{label}.severity must be non-negative, got {severity!r}")
|
|
309
|
+
if trials_int < 1:
|
|
310
|
+
raise ValueError(f"{label}.trials must be >= 1, got {trials_int!r}")
|
|
311
|
+
if successes_int < 0:
|
|
312
|
+
raise ValueError(f"{label}.successes must be non-negative, got {successes_int!r}")
|
|
313
|
+
if successes_int > trials_int:
|
|
314
|
+
raise ValueError(
|
|
315
|
+
f"{label}.successes ({successes_int!r}) must not exceed {label}.trials ({trials_int!r})"
|
|
316
|
+
)
|
|
317
|
+
return successes_int, trials_int
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@dataclass(frozen=True, kw_only=True)
|
|
321
|
+
class Cell:
|
|
322
|
+
"""One pooled row of counts at a single severity: the unit :meth:`Observations.cells`
|
|
323
|
+
returns and :class:`robustmargin.Fit` stores its per-level table as.
|
|
324
|
+
|
|
325
|
+
Attributes
|
|
326
|
+
----------
|
|
327
|
+
severity
|
|
328
|
+
The (non-negative, finite) severity level this cell was pooled at. Must not be a
|
|
329
|
+
``bool``.
|
|
330
|
+
successes
|
|
331
|
+
The number of successes observed at this level. Accepts a whole-number ``float`` (for
|
|
332
|
+
example ``200.0``) as well as an ``int``; either way, the stored value is a genuine
|
|
333
|
+
``int`` (``type(cell.successes) is int``). A ``bool`` or a non-whole float (``2.5``)
|
|
334
|
+
raises.
|
|
335
|
+
trials
|
|
336
|
+
The number of trials observed at this level. Must be at least 1, and at least
|
|
337
|
+
``successes``. Accepts a whole-number ``float`` the same way ``successes`` does.
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
severity: float
|
|
341
|
+
successes: int
|
|
342
|
+
trials: int
|
|
343
|
+
|
|
344
|
+
def __post_init__(self) -> None:
|
|
345
|
+
successes, trials = _check_counts(
|
|
346
|
+
severity=self.severity, successes=self.successes, trials=self.trials, label="Cell"
|
|
347
|
+
)
|
|
348
|
+
object.__setattr__(self, "successes", successes)
|
|
349
|
+
object.__setattr__(self, "trials", trials)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass(frozen=True, kw_only=True)
|
|
353
|
+
class Observations:
|
|
354
|
+
"""Raw severity/outcome data for one series, either aggregated counts or per-observation
|
|
355
|
+
rows -- both stored the same way, with each per-observation row as ``trials=1``.
|
|
356
|
+
|
|
357
|
+
Severity is non-negative: a signed axis (for example a bidirectional mass offset) is split
|
|
358
|
+
by the caller into two ``Observations``, one per side. On a log axis, a severity of
|
|
359
|
+
exactly ``0.0`` is the zero-severity control; it is handled in the fitted likelihood,
|
|
360
|
+
never by taking ``log(0)`` and never by an epsilon substitute.
|
|
361
|
+
|
|
362
|
+
Attributes
|
|
363
|
+
----------
|
|
364
|
+
axis
|
|
365
|
+
The :class:`Axis` this series was measured on.
|
|
366
|
+
outcome
|
|
367
|
+
A short, non-empty, domain-neutral name for what "success" means (for example
|
|
368
|
+
``"success"`` or ``"consistent"``). Required, with no default: a wrong default would
|
|
369
|
+
silently flip a threshold.
|
|
370
|
+
direction
|
|
371
|
+
Either ``"decreasing"`` (performance falls as severity rises) or ``"increasing"``.
|
|
372
|
+
Required, with no default, for the same reason as ``outcome``.
|
|
373
|
+
severity
|
|
374
|
+
One entry per row, non-negative and finite. Must not be a ``bool``.
|
|
375
|
+
successes
|
|
376
|
+
One entry per row, a non-negative whole number not exceeding the matching ``trials``
|
|
377
|
+
entry. Accepts a whole-number ``float`` (for example ``200.0``) as well as an ``int``;
|
|
378
|
+
either way the stored value is a genuine ``int``. A ``bool`` or a non-whole float
|
|
379
|
+
(``2.5``) raises.
|
|
380
|
+
trials
|
|
381
|
+
One entry per row, a whole number of at least 1. Accepts a whole-number ``float`` the
|
|
382
|
+
same way ``successes`` does.
|
|
383
|
+
cluster
|
|
384
|
+
Optional cluster ids, one per row, each a ``str`` or non-bool ``int`` (never ``None``
|
|
385
|
+
or a ``bool``). Any method assuming independence must refuse clustered input unless the
|
|
386
|
+
caller explicitly asks for it; ``Observations`` itself never drops or ignores these
|
|
387
|
+
ids.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
axis: Axis
|
|
391
|
+
outcome: str
|
|
392
|
+
direction: str
|
|
393
|
+
severity: tuple[float, ...]
|
|
394
|
+
successes: tuple[int, ...]
|
|
395
|
+
trials: tuple[int, ...]
|
|
396
|
+
cluster: tuple[str | int, ...] | None = None
|
|
397
|
+
|
|
398
|
+
def __post_init__(self) -> None:
|
|
399
|
+
object.__setattr__(self, "severity", tuple(self.severity))
|
|
400
|
+
object.__setattr__(self, "successes", tuple(self.successes))
|
|
401
|
+
object.__setattr__(self, "trials", tuple(self.trials))
|
|
402
|
+
if self.cluster is not None:
|
|
403
|
+
object.__setattr__(self, "cluster", tuple(self.cluster))
|
|
404
|
+
|
|
405
|
+
if not self.outcome:
|
|
406
|
+
raise ValueError("Observations.outcome must be non-empty")
|
|
407
|
+
if self.direction not in _VALID_DIRECTIONS:
|
|
408
|
+
raise ValueError(
|
|
409
|
+
f"Observations.direction must be one of {sorted(_VALID_DIRECTIONS)}, "
|
|
410
|
+
f"got {self.direction!r}"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
n = len(self.severity)
|
|
414
|
+
if n == 0:
|
|
415
|
+
raise ValueError("Observations must not be empty")
|
|
416
|
+
if len(self.successes) != n or len(self.trials) != n:
|
|
417
|
+
raise ValueError(
|
|
418
|
+
"Observations.severity, .successes and .trials must have equal length: "
|
|
419
|
+
f"got {n}, {len(self.successes)}, {len(self.trials)}"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# ``_check_counts`` rejects a bool or non-finite severity, and accepts a whole-number
|
|
423
|
+
# float for successes/trials (for example ``200.0``) the same way direct ``Cell``
|
|
424
|
+
# construction does, coercing both to ``int`` for storage.
|
|
425
|
+
coerced_successes: list[int] = []
|
|
426
|
+
coerced_trials: list[int] = []
|
|
427
|
+
for severity, successes, trials in zip(
|
|
428
|
+
self.severity, self.successes, self.trials, strict=True
|
|
429
|
+
):
|
|
430
|
+
row_successes, row_trials = _check_counts(
|
|
431
|
+
severity=severity, successes=successes, trials=trials, label="Observations"
|
|
432
|
+
)
|
|
433
|
+
coerced_successes.append(row_successes)
|
|
434
|
+
coerced_trials.append(row_trials)
|
|
435
|
+
object.__setattr__(self, "successes", tuple(coerced_successes))
|
|
436
|
+
object.__setattr__(self, "trials", tuple(coerced_trials))
|
|
437
|
+
|
|
438
|
+
if self.cluster is not None:
|
|
439
|
+
if len(self.cluster) != n:
|
|
440
|
+
raise ValueError(
|
|
441
|
+
f"Observations.cluster must have length {n} to match severity, "
|
|
442
|
+
f"got {len(self.cluster)}"
|
|
443
|
+
)
|
|
444
|
+
for cluster_id in self.cluster:
|
|
445
|
+
if cluster_id is None or isinstance(cluster_id, bool):
|
|
446
|
+
raise ValueError(
|
|
447
|
+
"Observations.cluster ids must be a str or int, never None or bool, "
|
|
448
|
+
f"got {cluster_id!r}"
|
|
449
|
+
)
|
|
450
|
+
if not isinstance(cluster_id, (str, int)):
|
|
451
|
+
raise ValueError(
|
|
452
|
+
f"Observations.cluster ids must be a str or int, got {cluster_id!r}"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
@classmethod
|
|
456
|
+
def from_counts(
|
|
457
|
+
cls,
|
|
458
|
+
axis: Axis,
|
|
459
|
+
*,
|
|
460
|
+
severity: Sequence[float],
|
|
461
|
+
successes: Sequence[float],
|
|
462
|
+
trials: Sequence[float],
|
|
463
|
+
outcome: str,
|
|
464
|
+
direction: str,
|
|
465
|
+
cluster: Sequence[str | int] | None = None,
|
|
466
|
+
) -> Observations:
|
|
467
|
+
"""Build ``Observations`` from already-aggregated ``(severity, successes, trials)``
|
|
468
|
+
rows.
|
|
469
|
+
|
|
470
|
+
``successes`` and ``trials`` accept whole-number floats (for example ``200.0``) as well
|
|
471
|
+
as ints; a non-whole value (``2.5``) raises.
|
|
472
|
+
"""
|
|
473
|
+
return cls(
|
|
474
|
+
axis=axis,
|
|
475
|
+
outcome=outcome,
|
|
476
|
+
direction=direction,
|
|
477
|
+
severity=tuple(float(v) for v in severity),
|
|
478
|
+
successes=tuple(_as_whole_int(v, label="successes") for v in successes),
|
|
479
|
+
trials=tuple(_as_whole_int(v, label="trials") for v in trials),
|
|
480
|
+
cluster=tuple(cluster) if cluster is not None else None,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
@classmethod
|
|
484
|
+
def from_observations(
|
|
485
|
+
cls,
|
|
486
|
+
axis: Axis,
|
|
487
|
+
*,
|
|
488
|
+
severity: Sequence[float],
|
|
489
|
+
success: Sequence[float],
|
|
490
|
+
outcome: str,
|
|
491
|
+
direction: str,
|
|
492
|
+
cluster: Sequence[str | int] | None = None,
|
|
493
|
+
) -> Observations:
|
|
494
|
+
"""Build ``Observations`` from one row per observation, each stored with ``trials=1``.
|
|
495
|
+
|
|
496
|
+
``success`` entries must be ``0``, ``1``, ``True`` or ``False``; anything else raises.
|
|
497
|
+
"""
|
|
498
|
+
successes: list[int] = []
|
|
499
|
+
for value in success:
|
|
500
|
+
if isinstance(value, bool):
|
|
501
|
+
successes.append(1 if value else 0)
|
|
502
|
+
elif value in (0, 1):
|
|
503
|
+
successes.append(int(value))
|
|
504
|
+
else:
|
|
505
|
+
raise ValueError(
|
|
506
|
+
f"Observations.from_observations: success entries must be 0, 1, True or "
|
|
507
|
+
f"False, got {value!r}"
|
|
508
|
+
)
|
|
509
|
+
return cls(
|
|
510
|
+
axis=axis,
|
|
511
|
+
outcome=outcome,
|
|
512
|
+
direction=direction,
|
|
513
|
+
severity=tuple(float(v) for v in severity),
|
|
514
|
+
successes=tuple(successes),
|
|
515
|
+
trials=tuple(1 for _ in successes),
|
|
516
|
+
cluster=tuple(cluster) if cluster is not None else None,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
def cells(self) -> tuple[Cell, ...]:
|
|
520
|
+
"""Pool rows with exactly equal severity by summing counts, sorted ascending.
|
|
521
|
+
|
|
522
|
+
Returns
|
|
523
|
+
-------
|
|
524
|
+
tuple[Cell, ...]
|
|
525
|
+
One :class:`Cell` per distinct severity value, ascending.
|
|
526
|
+
"""
|
|
527
|
+
pooled: dict[float, tuple[int, int]] = {}
|
|
528
|
+
for severity, successes, trials in zip(
|
|
529
|
+
self.severity, self.successes, self.trials, strict=True
|
|
530
|
+
):
|
|
531
|
+
existing_successes, existing_trials = pooled.get(severity, (0, 0))
|
|
532
|
+
pooled[severity] = (existing_successes + successes, existing_trials + trials)
|
|
533
|
+
return tuple(
|
|
534
|
+
Cell(severity=severity, successes=successes, trials=trials)
|
|
535
|
+
for severity, (successes, trials) in sorted(pooled.items())
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
@property
|
|
539
|
+
def is_clustered(self) -> bool:
|
|
540
|
+
"""Whether this series carries cluster ids."""
|
|
541
|
+
return self.cluster is not None
|