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,722 @@
|
|
|
1
|
+
"""Fit diagnostics (model-only) and the data-only adjacent-level monotonicity check.
|
|
2
|
+
|
|
3
|
+
Two independent things live here, deliberately kept apart:
|
|
4
|
+
|
|
5
|
+
- :class:`Diagnostics` / :func:`diagnose` need a converged fit -- goodness of fit, dispersion,
|
|
6
|
+
and link comparison are all statements about how well a fitted curve matches the data, so they
|
|
7
|
+
are ``None`` whenever :attr:`~robustmargin.Fit.status` is not :data:`~robustmargin.Status.OK`
|
|
8
|
+
(a failed fit carries no model-derived numbers).
|
|
9
|
+
- :class:`MonotonicityCheck` / :func:`check_monotonicity` need only raw per-level counts -- an
|
|
10
|
+
exact one-sided Fisher test on adjacent severity levels, Holm-corrected across the family. It
|
|
11
|
+
therefore runs on *every* :func:`~robustmargin.fit_dose_response` return path, whatever the
|
|
12
|
+
``Status``, because non-monotone data is a common cause of a failed fit and disappearing the
|
|
13
|
+
check exactly then would hide the finding it exists to surface.
|
|
14
|
+
|
|
15
|
+
Both are reported and never acted on automatically: nothing here changes a
|
|
16
|
+
reported number, chooses a link, or corrects for dispersion. ``Fit.link`` stays whatever the
|
|
17
|
+
caller passed.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
23
|
+
from collections.abc import Mapping
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
from scipy.special import xlogy
|
|
29
|
+
from scipy.stats import fisher_exact
|
|
30
|
+
from statsmodels.stats.multitest import multipletests
|
|
31
|
+
|
|
32
|
+
from robustmargin.types import Cell, Observations, Status, _check_finite_number
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
# Only for the `diagnose(fit: Fit)` annotation. `from __future__ import annotations` (above)
|
|
36
|
+
# makes every annotation in this module a string, so this import is never evaluated at
|
|
37
|
+
# runtime -- it exists purely for mypy, and keeps this module from ever importing
|
|
38
|
+
# robustmargin.models at import time (models.py imports *this* module at its own top level, to
|
|
39
|
+
# build Fit.diagnostics/Fit.monotonicity and to call diagnose()/check_monotonicity() from
|
|
40
|
+
# fit_dose_response; the reverse, real import would be a cycle).
|
|
41
|
+
from robustmargin.models import Fit
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"AdjacentPair",
|
|
45
|
+
"Diagnostics",
|
|
46
|
+
"MonotonicityCheck",
|
|
47
|
+
"check_monotonicity",
|
|
48
|
+
"diagnose",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# A warning when phi > 1.5 -- strict, an exact 1.5 does not warn.
|
|
52
|
+
_DISPERSION_WARNING_THRESHOLD = 1.5
|
|
53
|
+
_OVERDISPERSED_WARNING_PREFIX = "OVERDISPERSED: "
|
|
54
|
+
_SPARSE_CELLS_WARNING_PREFIX = "SPARSE_CELLS: "
|
|
55
|
+
# A counted cell is flagged on one side (successes or failures) only when *both* hold -- the
|
|
56
|
+
# expected count on that side is below this cutoff, and the *observed* count on that side is at
|
|
57
|
+
# least 1. A small expected count with nothing observed there is a clean plateau, not a finding;
|
|
58
|
+
# an observed event against a merely-moderate expectation (down to about 0.43 near a design's
|
|
59
|
+
# transition) is ordinary too -- only a genuine event against an expectation this small is the
|
|
60
|
+
# "dominated by a single event" pathology the warning exists for. Report-only: nothing here is
|
|
61
|
+
# recomputed. A fixed constant, not a parameter; changing it is a statistical-method change.
|
|
62
|
+
_SPARSE_EXPECTED_CUTOFF = 0.1
|
|
63
|
+
# R's own `binomial()$linkinv` clamp (`.Machine$double.eps`).
|
|
64
|
+
_PROBABILITY_EPS = float(np.finfo(float).eps)
|
|
65
|
+
|
|
66
|
+
# Link comparison covers exactly these three binomial links.
|
|
67
|
+
_COMPARISON_LINKS: tuple[str, ...] = ("probit", "logit", "cloglog")
|
|
68
|
+
|
|
69
|
+
_MONOTONICITY_METHOD = "fisher_exact_one_sided_holm"
|
|
70
|
+
_VALID_DIRECTIONS = frozenset({"decreasing", "increasing"})
|
|
71
|
+
_VALID_DEPENDENCE_INPUTS = frozenset({"independent"})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, kw_only=True)
|
|
75
|
+
class Diagnostics:
|
|
76
|
+
"""Model-only fit diagnostics: goodness of fit, dispersion, and link comparison. The
|
|
77
|
+
monotonicity check is not part of this type -- see :class:`MonotonicityCheck`.
|
|
78
|
+
|
|
79
|
+
Built only from a converged fit (:func:`diagnose` requires ``fit.status is Status.OK``), and
|
|
80
|
+
attached to :attr:`~robustmargin.Fit.diagnostics` only in that case: ``None`` whenever the fit
|
|
81
|
+
failed (a failed fit carries no model-derived numbers). Contrast :class:`MonotonicityCheck`,
|
|
82
|
+
attached to :attr:`~robustmargin.Fit.monotonicity` on *every* ``fit_dose_response`` return path
|
|
83
|
+
regardless of ``Status`` -- the two fields on ``Fit`` have different nullability for exactly
|
|
84
|
+
this reason.
|
|
85
|
+
|
|
86
|
+
Attributes
|
|
87
|
+
----------
|
|
88
|
+
deviance
|
|
89
|
+
The binomial deviance on the per-severity-level cells, or ``None`` when ``df <= 0``
|
|
90
|
+
(see ``df`` below) -- not a failed fit, just no goodness-of-fit statistic to report. A
|
|
91
|
+
cell is excluded from this sum only when it is *structurally* deterministic under the
|
|
92
|
+
model -- a log-axis cell at severity exactly ``0.0`` when ``upper`` is *fixed* at exactly
|
|
93
|
+
``1.0`` (``direction="decreasing"``); that cell has zero variance and zero deviance by
|
|
94
|
+
construction. Every other cell counts, including one whose fitted probability happens to
|
|
95
|
+
round to exactly ``0`` or ``1`` in double precision: probabilities are computed to avoid
|
|
96
|
+
needless cancellation and clamped to ``[eps, 1 - eps]`` (``eps`` the machine epsilon,
|
|
97
|
+
matching R's own ``binomial()$linkinv``), so such a cell contributes approximately zero
|
|
98
|
+
rather than a ``0/0``. The exclusion is by structure, not by *computed* value, so ``df``
|
|
99
|
+
does not depend on the link.
|
|
100
|
+
pearson_chi2
|
|
101
|
+
The Pearson chi-square statistic on the same cells, with the same exclusion and the
|
|
102
|
+
same ``None``-on-``df <= 0`` rule as ``deviance``.
|
|
103
|
+
df
|
|
104
|
+
Degrees of freedom: the number of cells counted towards ``deviance``/``pearson_chi2``
|
|
105
|
+
(after the structural exclusion above) minus the number of estimated parameters. Always
|
|
106
|
+
an integer, even when it is zero or negative.
|
|
107
|
+
dispersion
|
|
108
|
+
``pearson_chi2 / df`` (``phi``), or ``None`` exactly when ``pearson_chi2`` is ``None``
|
|
109
|
+
(``df <= 0``). A value strictly greater than ``1.5`` adds a warning starting with
|
|
110
|
+
``"OVERDISPERSED: "``; nothing is rescaled and no interval changes as a result (reported,
|
|
111
|
+
never acted on). It is a screen, not a test: under a correctly specified model
|
|
112
|
+
``phi > 1.5`` fires about 21% of the time at ``df`` 1 to 3 and about 15% at ``df`` 8.
|
|
113
|
+
``phi`` can be dominated by a single observed event against a tiny expected
|
|
114
|
+
count -- see ``warnings``' ``"SPARSE_CELLS: "`` entry, which flags exactly that case without
|
|
115
|
+
changing ``dispersion`` itself.
|
|
116
|
+
link_aic
|
|
117
|
+
AIC for each of ``"probit"``, ``"logit"`` and ``"cloglog"``, refit under the same
|
|
118
|
+
``upper``/``lower`` specification as the diagnosed fit. The diagnosed fit's own link
|
|
119
|
+
reuses its own ``log_likelihood`` and parameter count directly (never refit, so it is
|
|
120
|
+
exact by construction); the other two links are refit through the existing statsmodels
|
|
121
|
+
fitting paths (never a hand-rolled fitter). ``None`` for a link whose refit does not
|
|
122
|
+
reach :data:`~robustmargin.Status.OK` -- see ``link_status``.
|
|
123
|
+
link_status
|
|
124
|
+
The :class:`~robustmargin.Status` of each link's own fit (the diagnosed fit's own status,
|
|
125
|
+
which is always ``OK``, for its own link; the refit's status for the other two).
|
|
126
|
+
``link_aic[link] is None`` if and only if ``link_status[link] is not Status.OK``.
|
|
127
|
+
warnings
|
|
128
|
+
Free-text notes: the ``df <= 0`` reason, an ``"OVERDISPERSED: "`` token, or a
|
|
129
|
+
``"SPARSE_CELLS: "`` token naming a counted cell flagged on one side (successes or
|
|
130
|
+
failures) because that side's expected count is below ``0.1`` *and* at least one event
|
|
131
|
+
was actually observed there -- report-only, like ``"OVERDISPERSED: "``: it changes no
|
|
132
|
+
number, only says that ``pearson_chi2`` and ``dispersion`` are unreliable at that cell.
|
|
133
|
+
Empty by default.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
deviance: float | None
|
|
137
|
+
pearson_chi2: float | None
|
|
138
|
+
df: int
|
|
139
|
+
dispersion: float | None
|
|
140
|
+
link_aic: Mapping[str, float | None]
|
|
141
|
+
link_status: Mapping[str, Status]
|
|
142
|
+
warnings: tuple[str, ...] = ()
|
|
143
|
+
|
|
144
|
+
def __post_init__(self) -> None:
|
|
145
|
+
object.__setattr__(self, "warnings", tuple(self.warnings))
|
|
146
|
+
object.__setattr__(self, "link_aic", dict(self.link_aic))
|
|
147
|
+
object.__setattr__(self, "link_status", dict(self.link_status))
|
|
148
|
+
|
|
149
|
+
if isinstance(self.df, bool) or not isinstance(self.df, int):
|
|
150
|
+
raise ValueError(f"Diagnostics.df must be a genuine int, got {self.df!r}")
|
|
151
|
+
|
|
152
|
+
gof_fields = (self.deviance, self.pearson_chi2, self.dispersion)
|
|
153
|
+
gof_all_none = all(v is None for v in gof_fields)
|
|
154
|
+
gof_all_set = all(v is not None for v in gof_fields)
|
|
155
|
+
if not (gof_all_none or gof_all_set):
|
|
156
|
+
raise ValueError(
|
|
157
|
+
"Diagnostics.deviance, .pearson_chi2 and .dispersion must be either all None "
|
|
158
|
+
f"(df <= 0) or all set, got deviance={self.deviance!r}, "
|
|
159
|
+
f"pearson_chi2={self.pearson_chi2!r}, dispersion={self.dispersion!r}"
|
|
160
|
+
)
|
|
161
|
+
if gof_all_none and self.df > 0:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"Diagnostics.deviance/.pearson_chi2/.dispersion are None, but df "
|
|
164
|
+
f"({self.df!r}) is > 0 -- they are only None when df <= 0"
|
|
165
|
+
)
|
|
166
|
+
if gof_all_set:
|
|
167
|
+
assert self.deviance is not None and self.pearson_chi2 is not None
|
|
168
|
+
assert self.dispersion is not None
|
|
169
|
+
_check_finite_number(self.deviance, label="Diagnostics.deviance")
|
|
170
|
+
_check_finite_number(self.pearson_chi2, label="Diagnostics.pearson_chi2")
|
|
171
|
+
_check_finite_number(self.dispersion, label="Diagnostics.dispersion")
|
|
172
|
+
if self.df <= 0:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"Diagnostics.df is {self.df!r} (<= 0), so deviance/pearson_chi2/dispersion "
|
|
175
|
+
"must be None"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if set(self.link_aic) != set(_COMPARISON_LINKS):
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"Diagnostics.link_aic must have exactly the keys {sorted(_COMPARISON_LINKS)}, "
|
|
181
|
+
f"got {sorted(self.link_aic)}"
|
|
182
|
+
)
|
|
183
|
+
if set(self.link_status) != set(_COMPARISON_LINKS):
|
|
184
|
+
raise ValueError(
|
|
185
|
+
"Diagnostics.link_status must have exactly the keys "
|
|
186
|
+
f"{sorted(_COMPARISON_LINKS)}, got {sorted(self.link_status)}"
|
|
187
|
+
)
|
|
188
|
+
for link in _COMPARISON_LINKS:
|
|
189
|
+
aic = self.link_aic[link]
|
|
190
|
+
status = self.link_status[link]
|
|
191
|
+
if not isinstance(status, Status):
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"Diagnostics.link_status[{link!r}] must be a Status, got {status!r}"
|
|
194
|
+
)
|
|
195
|
+
if aic is None:
|
|
196
|
+
if status is Status.OK:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"Diagnostics.link_aic[{link!r}] is None, but "
|
|
199
|
+
f"link_status[{link!r}] is Status.OK -- an OK refit always has an AIC"
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
_check_finite_number(aic, label=f"Diagnostics.link_aic[{link!r}]")
|
|
203
|
+
if status is not Status.OK:
|
|
204
|
+
raise ValueError(
|
|
205
|
+
f"Diagnostics.link_aic[{link!r}] is set, but link_status[{link!r}] is "
|
|
206
|
+
f"{status!r}, not Status.OK"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True, kw_only=True)
|
|
211
|
+
class AdjacentPair:
|
|
212
|
+
"""One adjacent-severity-level pair from a :class:`MonotonicityCheck`: the raw counts at
|
|
213
|
+
both levels, the one-sided Fisher exact test between them, and its Holm-adjusted p-value.
|
|
214
|
+
|
|
215
|
+
Attributes
|
|
216
|
+
----------
|
|
217
|
+
severity_low, severity_high
|
|
218
|
+
The two adjacent severity levels, ``severity_low < severity_high``.
|
|
219
|
+
successes_low, trials_low, successes_high, trials_high
|
|
220
|
+
The raw pooled counts at each level (:meth:`~robustmargin.Observations.cells`).
|
|
221
|
+
p_value
|
|
222
|
+
The raw one-sided Fisher exact p-value for a reversal against ``direction`` (that is, the
|
|
223
|
+
higher severity performing *better* than the lower one, which is evidence against
|
|
224
|
+
``direction="decreasing"``; the mirror image against ``"increasing"``) --
|
|
225
|
+
:func:`scipy.stats.fisher_exact` on
|
|
226
|
+
``[[successes_high, failures_high], [successes_low, failures_low]]``,
|
|
227
|
+
``alternative="greater"`` for ``direction="decreasing"``, ``"less"`` for
|
|
228
|
+
``"increasing"``.
|
|
229
|
+
holm_adjusted_p
|
|
230
|
+
``p_value`` after a Holm step-down correction across every pair in the same
|
|
231
|
+
:class:`MonotonicityCheck` (:func:`statsmodels.stats.multitest.multipletests`,
|
|
232
|
+
``method="holm"``).
|
|
233
|
+
flagged
|
|
234
|
+
``holm_adjusted_p < alpha`` (``alpha`` recorded on the enclosing
|
|
235
|
+
:class:`MonotonicityCheck`).
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
severity_low: float
|
|
239
|
+
severity_high: float
|
|
240
|
+
successes_low: int
|
|
241
|
+
trials_low: int
|
|
242
|
+
successes_high: int
|
|
243
|
+
trials_high: int
|
|
244
|
+
p_value: float
|
|
245
|
+
holm_adjusted_p: float
|
|
246
|
+
flagged: bool
|
|
247
|
+
|
|
248
|
+
def __post_init__(self) -> None:
|
|
249
|
+
_check_finite_number(self.severity_low, label="AdjacentPair.severity_low")
|
|
250
|
+
_check_finite_number(self.severity_high, label="AdjacentPair.severity_high")
|
|
251
|
+
if not self.severity_low < self.severity_high:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
"AdjacentPair.severity_low must be strictly less than .severity_high, got "
|
|
254
|
+
f"severity_low={self.severity_low!r}, severity_high={self.severity_high!r}"
|
|
255
|
+
)
|
|
256
|
+
for label, value in (
|
|
257
|
+
("successes_low", self.successes_low),
|
|
258
|
+
("trials_low", self.trials_low),
|
|
259
|
+
("successes_high", self.successes_high),
|
|
260
|
+
("trials_high", self.trials_high),
|
|
261
|
+
):
|
|
262
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
263
|
+
raise ValueError(f"AdjacentPair.{label} must be a genuine int, got {value!r}")
|
|
264
|
+
if not (0 <= self.successes_low <= self.trials_low):
|
|
265
|
+
raise ValueError(
|
|
266
|
+
"AdjacentPair requires 0 <= successes_low <= trials_low, got "
|
|
267
|
+
f"successes_low={self.successes_low!r}, trials_low={self.trials_low!r}"
|
|
268
|
+
)
|
|
269
|
+
if not (0 <= self.successes_high <= self.trials_high):
|
|
270
|
+
raise ValueError(
|
|
271
|
+
"AdjacentPair requires 0 <= successes_high <= trials_high, got "
|
|
272
|
+
f"successes_high={self.successes_high!r}, trials_high={self.trials_high!r}"
|
|
273
|
+
)
|
|
274
|
+
_check_finite_number(self.p_value, label="AdjacentPair.p_value")
|
|
275
|
+
_check_finite_number(self.holm_adjusted_p, label="AdjacentPair.holm_adjusted_p")
|
|
276
|
+
if not (0.0 <= self.p_value <= 1.0):
|
|
277
|
+
raise ValueError(f"AdjacentPair.p_value must lie in [0, 1], got {self.p_value!r}")
|
|
278
|
+
if not (0.0 <= self.holm_adjusted_p <= 1.0):
|
|
279
|
+
raise ValueError(
|
|
280
|
+
f"AdjacentPair.holm_adjusted_p must lie in [0, 1], got {self.holm_adjusted_p!r}"
|
|
281
|
+
)
|
|
282
|
+
if not isinstance(self.flagged, bool):
|
|
283
|
+
raise ValueError(f"AdjacentPair.flagged must be a genuine bool, got {self.flagged!r}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(frozen=True, kw_only=True)
|
|
287
|
+
class MonotonicityCheck:
|
|
288
|
+
"""A data-only adjacent-level monotonicity check: for each pair of adjacent severity levels
|
|
289
|
+
in the data, a one-sided Fisher exact test for "the higher severity performs better" under
|
|
290
|
+
``direction``, Holm-corrected across the whole family.
|
|
291
|
+
|
|
292
|
+
Needs only raw counts, never a fitted curve, so it is attached to
|
|
293
|
+
:attr:`~robustmargin.Fit.monotonicity` on *every* :func:`~robustmargin.fit_dose_response` return
|
|
294
|
+
path, whatever the resulting :class:`~robustmargin.Status` -- contrast :class:`Diagnostics`,
|
|
295
|
+
which needs a converged fit and is ``None`` otherwise. See :func:`check_monotonicity`.
|
|
296
|
+
|
|
297
|
+
Attributes
|
|
298
|
+
----------
|
|
299
|
+
direction
|
|
300
|
+
``"decreasing"`` or ``"increasing"``, read from the ``Observations`` the check was built
|
|
301
|
+
from (never passed separately, so the check cannot disagree with its own data).
|
|
302
|
+
alpha
|
|
303
|
+
The significance level ``flagged`` is decided at, strictly between 0 and 1.
|
|
304
|
+
dependence
|
|
305
|
+
``"independent"`` in this release: the check assumes independent trials. Recorded so a
|
|
306
|
+
serialised result states the assumption it relies on.
|
|
307
|
+
method
|
|
308
|
+
Names the statistical method. Always ``"fisher_exact_one_sided_holm"`` in this release.
|
|
309
|
+
pairs
|
|
310
|
+
One :class:`AdjacentPair` per adjacent pair of severity levels, ascending. Empty when
|
|
311
|
+
fewer than two distinct severity levels exist (``warnings`` then names the reason).
|
|
312
|
+
any_flagged
|
|
313
|
+
``True`` exactly when at least one entry of ``pairs`` is ``flagged``.
|
|
314
|
+
warnings
|
|
315
|
+
Free-text notes (for example, naming a degenerate single-level input). Empty by default.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
direction: str
|
|
319
|
+
alpha: float
|
|
320
|
+
dependence: str
|
|
321
|
+
pairs: tuple[AdjacentPair, ...]
|
|
322
|
+
any_flagged: bool
|
|
323
|
+
method: str = _MONOTONICITY_METHOD
|
|
324
|
+
warnings: tuple[str, ...] = ()
|
|
325
|
+
|
|
326
|
+
def __post_init__(self) -> None:
|
|
327
|
+
object.__setattr__(self, "pairs", tuple(self.pairs))
|
|
328
|
+
object.__setattr__(self, "warnings", tuple(self.warnings))
|
|
329
|
+
|
|
330
|
+
if self.direction not in _VALID_DIRECTIONS:
|
|
331
|
+
raise ValueError(
|
|
332
|
+
f"MonotonicityCheck.direction must be one of {sorted(_VALID_DIRECTIONS)}, "
|
|
333
|
+
f"got {self.direction!r}"
|
|
334
|
+
)
|
|
335
|
+
_check_finite_number(self.alpha, label="MonotonicityCheck.alpha")
|
|
336
|
+
if not (0.0 < self.alpha < 1.0):
|
|
337
|
+
raise ValueError(
|
|
338
|
+
f"MonotonicityCheck.alpha must satisfy 0 < alpha < 1, got {self.alpha!r}"
|
|
339
|
+
)
|
|
340
|
+
if self.dependence not in _VALID_DEPENDENCE_INPUTS:
|
|
341
|
+
raise ValueError(
|
|
342
|
+
"MonotonicityCheck.dependence must be one of "
|
|
343
|
+
f"{sorted(_VALID_DEPENDENCE_INPUTS)}, got {self.dependence!r}"
|
|
344
|
+
)
|
|
345
|
+
if self.method != _MONOTONICITY_METHOD:
|
|
346
|
+
raise ValueError(
|
|
347
|
+
f"MonotonicityCheck.method must be {_MONOTONICITY_METHOD!r}, got {self.method!r}"
|
|
348
|
+
)
|
|
349
|
+
if not isinstance(self.any_flagged, bool):
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"MonotonicityCheck.any_flagged must be a genuine bool, got {self.any_flagged!r}"
|
|
352
|
+
)
|
|
353
|
+
for pair in self.pairs:
|
|
354
|
+
if not isinstance(pair, AdjacentPair):
|
|
355
|
+
raise ValueError(
|
|
356
|
+
f"MonotonicityCheck.pairs entries must each be an AdjacentPair, got {pair!r}"
|
|
357
|
+
)
|
|
358
|
+
expected_any_flagged = any(pair.flagged for pair in self.pairs)
|
|
359
|
+
if self.any_flagged != expected_any_flagged:
|
|
360
|
+
raise ValueError(
|
|
361
|
+
f"MonotonicityCheck.any_flagged ({self.any_flagged!r}) must equal "
|
|
362
|
+
f"any(pair.flagged for pair in pairs) ({expected_any_flagged!r})"
|
|
363
|
+
)
|
|
364
|
+
for pair in self.pairs:
|
|
365
|
+
expected_flagged = pair.holm_adjusted_p < self.alpha
|
|
366
|
+
if pair.flagged != expected_flagged:
|
|
367
|
+
raise ValueError(
|
|
368
|
+
f"AdjacentPair.flagged ({pair.flagged!r}) must equal "
|
|
369
|
+
f"holm_adjusted_p < alpha ({pair.holm_adjusted_p!r} < {self.alpha!r} = "
|
|
370
|
+
f"{expected_flagged!r})"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def check_monotonicity(
|
|
375
|
+
obs: Observations, *, dependence: str | None = None, alpha: float = 0.05
|
|
376
|
+
) -> MonotonicityCheck:
|
|
377
|
+
"""Test every pair of adjacent severity levels in ``obs`` for a reversal: a one-sided Fisher
|
|
378
|
+
exact test for "the higher severity performs better" under ``obs.direction``,
|
|
379
|
+
Holm-corrected across the whole family.
|
|
380
|
+
|
|
381
|
+
Levels are built from ``obs.cells()`` -- successes/trials pooled per exact severity, sorted
|
|
382
|
+
ascending, exactly like every other cell-based computation in this package (aggregated
|
|
383
|
+
and per-observation input give identical results). The zero-severity control is a level like
|
|
384
|
+
any other, and the control-to-first-level pair is always in the family, whether or not the
|
|
385
|
+
likelihood a fit built from the same data used the control row (a fit drops an
|
|
386
|
+
all-success control under a fixed ``upper=1.0``; this function never reads a fit at all).
|
|
387
|
+
|
|
388
|
+
Parameters
|
|
389
|
+
----------
|
|
390
|
+
obs
|
|
391
|
+
The severity/outcome data to test.
|
|
392
|
+
dependence
|
|
393
|
+
``None`` (the default) resolves to ``"independent"`` unless ``obs.cluster`` is set, in
|
|
394
|
+
which case omitting it raises (a method assuming independence must refuse clustered
|
|
395
|
+
input unless the caller explicitly opts in). ``"independent"`` may always be passed
|
|
396
|
+
explicitly; the resulting ``MonotonicityCheck.dependence`` is always ``"independent"`` in
|
|
397
|
+
this release.
|
|
398
|
+
alpha
|
|
399
|
+
The significance level ``flagged`` is decided at (``holm_adjusted_p < alpha``), strictly
|
|
400
|
+
between 0 and 1. Defaults to ``0.05``.
|
|
401
|
+
|
|
402
|
+
Returns
|
|
403
|
+
-------
|
|
404
|
+
MonotonicityCheck
|
|
405
|
+
``pairs`` is empty (with a warning naming the reason) when ``obs`` has fewer than two
|
|
406
|
+
distinct severity levels.
|
|
407
|
+
|
|
408
|
+
Raises
|
|
409
|
+
------
|
|
410
|
+
ValueError
|
|
411
|
+
If ``dependence`` is not ``None`` or ``"independent"``; if ``obs.cluster`` is set and
|
|
412
|
+
``dependence`` is ``None``; or if ``alpha`` is not a finite number strictly between 0
|
|
413
|
+
and 1.
|
|
414
|
+
"""
|
|
415
|
+
if dependence is not None and dependence not in _VALID_DEPENDENCE_INPUTS:
|
|
416
|
+
raise ValueError(
|
|
417
|
+
"check_monotonicity: dependence must be None or 'independent' in this release, got "
|
|
418
|
+
f"{dependence!r} (the check assumes independent trials; a paired/clustered design "
|
|
419
|
+
"needs its own method, deferred to a future decision)"
|
|
420
|
+
)
|
|
421
|
+
if obs.cluster is not None and dependence is None:
|
|
422
|
+
raise ValueError(
|
|
423
|
+
"check_monotonicity: obs carries cluster ids (obs.cluster is set); a method that "
|
|
424
|
+
"assumes independence must not run on clustered data unless the caller passes "
|
|
425
|
+
"dependence='independent' explicitly"
|
|
426
|
+
)
|
|
427
|
+
_check_finite_number(alpha, label="check_monotonicity alpha")
|
|
428
|
+
if not (0.0 < alpha < 1.0):
|
|
429
|
+
raise ValueError(f"check_monotonicity: alpha must satisfy 0 < alpha < 1, got {alpha!r}")
|
|
430
|
+
|
|
431
|
+
resolved_dependence = "independent"
|
|
432
|
+
direction = obs.direction
|
|
433
|
+
alternative = "greater" if direction == "decreasing" else "less"
|
|
434
|
+
cells = obs.cells()
|
|
435
|
+
|
|
436
|
+
if len(cells) < 2:
|
|
437
|
+
return MonotonicityCheck(
|
|
438
|
+
direction=direction,
|
|
439
|
+
alpha=alpha,
|
|
440
|
+
dependence=resolved_dependence,
|
|
441
|
+
pairs=(),
|
|
442
|
+
any_flagged=False,
|
|
443
|
+
warnings=(
|
|
444
|
+
"check_monotonicity: fewer than two distinct severity levels "
|
|
445
|
+
f"({len(cells)}) -- no adjacent pairs to test",
|
|
446
|
+
),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
level_pairs = list(zip(cells, cells[1:])) # deliberately not strict: cells[1:] is one shorter
|
|
450
|
+
raw_p_values: list[float] = []
|
|
451
|
+
for low, high in level_pairs:
|
|
452
|
+
failures_low = low.trials - low.successes
|
|
453
|
+
failures_high = high.trials - high.successes
|
|
454
|
+
table = [[high.successes, failures_high], [low.successes, failures_low]]
|
|
455
|
+
_, p_value = fisher_exact(table, alternative=alternative)
|
|
456
|
+
raw_p_values.append(float(p_value))
|
|
457
|
+
|
|
458
|
+
_, holm_adjusted, _, _ = multipletests(raw_p_values, method="holm")
|
|
459
|
+
|
|
460
|
+
pairs = tuple(
|
|
461
|
+
AdjacentPair(
|
|
462
|
+
severity_low=low.severity,
|
|
463
|
+
severity_high=high.severity,
|
|
464
|
+
successes_low=low.successes,
|
|
465
|
+
trials_low=low.trials,
|
|
466
|
+
successes_high=high.successes,
|
|
467
|
+
trials_high=high.trials,
|
|
468
|
+
p_value=p_value,
|
|
469
|
+
holm_adjusted_p=float(holm_p),
|
|
470
|
+
flagged=bool(holm_p < alpha),
|
|
471
|
+
)
|
|
472
|
+
for (low, high), p_value, holm_p in zip(
|
|
473
|
+
level_pairs, raw_p_values, holm_adjusted, strict=True
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
any_flagged = any(pair.flagged for pair in pairs)
|
|
477
|
+
|
|
478
|
+
return MonotonicityCheck(
|
|
479
|
+
direction=direction,
|
|
480
|
+
alpha=alpha,
|
|
481
|
+
dependence=resolved_dependence,
|
|
482
|
+
pairs=pairs,
|
|
483
|
+
any_flagged=any_flagged,
|
|
484
|
+
warnings=(),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _success_and_failure_probabilities(fit: Fit) -> tuple[np.ndarray, np.ndarray]:
|
|
489
|
+
"""``(p, 1 - p)`` per cell of ``fit.cells``, from the one shared curve formula in
|
|
490
|
+
``robustmargin.models`` -- never a second copy of it here. Imported
|
|
491
|
+
inside this function, not at module scope, since ``robustmargin.models`` imports this module at
|
|
492
|
+
*its* top level (see this module's own docstring on the import cycle); by the time any
|
|
493
|
+
``Fit`` exists to diagnose, ``robustmargin.models`` has already finished importing.
|
|
494
|
+
"""
|
|
495
|
+
from robustmargin.models import _curve_success_and_failure_probability # local: see docstring
|
|
496
|
+
|
|
497
|
+
assert fit.params is not None # diagnose() only calls this for an OK fit
|
|
498
|
+
mu = fit.params["mu"].value
|
|
499
|
+
s = fit.params["s"].value
|
|
500
|
+
upper = fit.params["upper"].value
|
|
501
|
+
lower = fit.params["lower"].value
|
|
502
|
+
severities = np.array([cell.severity for cell in fit.cells], dtype=np.float64)
|
|
503
|
+
return _curve_success_and_failure_probability(
|
|
504
|
+
severities, mu=mu, s=s, upper=upper, lower=lower, scale=fit.axis.scale, link=fit.link
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _refit_link(fit: Fit, link: str) -> Fit:
|
|
509
|
+
"""Refit ``fit``'s data under ``link``, holding the same ``upper``/``lower`` specification
|
|
510
|
+
(both fixed/estimate flags and, when fixed, the same value). Goes through the existing
|
|
511
|
+
private statsmodels fitting paths in ``robustmargin.models`` (no new optimizer or IRLS) --
|
|
512
|
+
imported here, inside the function, rather than at module scope, since
|
|
513
|
+
by the time any ``Fit`` exists to diagnose, ``robustmargin.models`` has already finished
|
|
514
|
+
importing this module (see this module's own docstring).
|
|
515
|
+
|
|
516
|
+
Skips the separation and control-incompatibility pre-checks ``fit_dose_response`` itself
|
|
517
|
+
runs before calling either fitting path: both are properties of the data and the
|
|
518
|
+
``upper``/``lower`` configuration, not of the link, and ``fit`` already reached
|
|
519
|
+
:data:`~robustmargin.Status.OK` under the very same data and configuration, so a different link
|
|
520
|
+
cannot newly trigger either one.
|
|
521
|
+
"""
|
|
522
|
+
from robustmargin.models import _fit_generic, _fit_glm # local: see docstring above
|
|
523
|
+
|
|
524
|
+
assert fit.params is not None # diagnose() only calls this for an OK fit
|
|
525
|
+
upper_param = fit.params["upper"]
|
|
526
|
+
lower_param = fit.params["lower"]
|
|
527
|
+
upper_is_estimate = not upper_param.fixed
|
|
528
|
+
lower_is_estimate = not lower_param.fixed
|
|
529
|
+
upper_value = math.nan if upper_is_estimate else upper_param.value
|
|
530
|
+
lower_value = math.nan if lower_is_estimate else lower_param.value
|
|
531
|
+
|
|
532
|
+
obs = Observations.from_counts(
|
|
533
|
+
fit.axis,
|
|
534
|
+
severity=[c.severity for c in fit.cells],
|
|
535
|
+
successes=[c.successes for c in fit.cells],
|
|
536
|
+
trials=[c.trials for c in fit.cells],
|
|
537
|
+
outcome=fit.outcome,
|
|
538
|
+
direction=fit.direction,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
if (
|
|
542
|
+
not upper_is_estimate
|
|
543
|
+
and not lower_is_estimate
|
|
544
|
+
and upper_value == 1.0
|
|
545
|
+
and lower_value == 0.0
|
|
546
|
+
):
|
|
547
|
+
return _fit_glm(obs, cells=fit.cells, cluster_ids=None, link=link)
|
|
548
|
+
return _fit_generic(
|
|
549
|
+
obs,
|
|
550
|
+
cells=fit.cells,
|
|
551
|
+
cluster_ids=None,
|
|
552
|
+
link=link,
|
|
553
|
+
upper_is_estimate=upper_is_estimate,
|
|
554
|
+
upper_value=upper_value,
|
|
555
|
+
lower_is_estimate=lower_is_estimate,
|
|
556
|
+
lower_value=lower_value,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _link_comparison(fit: Fit) -> tuple[dict[str, float | None], dict[str, Status]]:
|
|
561
|
+
"""AIC and status for each of ``_COMPARISON_LINKS``: the diagnosed fit's own link reuses its
|
|
562
|
+
own ``log_likelihood``/parameter count directly (exact, no refit); the other two are refit
|
|
563
|
+
via :func:`_refit_link` (so each call does two extra fits).
|
|
564
|
+
"""
|
|
565
|
+
assert fit.params is not None and fit.log_likelihood is not None # fit.status is OK
|
|
566
|
+
|
|
567
|
+
link_aic: dict[str, float | None] = {}
|
|
568
|
+
link_status: dict[str, Status] = {}
|
|
569
|
+
for link in _COMPARISON_LINKS:
|
|
570
|
+
if link == fit.link:
|
|
571
|
+
k = sum(1 for p in fit.params.values() if not p.fixed)
|
|
572
|
+
link_aic[link] = -2.0 * fit.log_likelihood + 2.0 * k
|
|
573
|
+
link_status[link] = Status.OK
|
|
574
|
+
continue
|
|
575
|
+
refit = _refit_link(fit, link)
|
|
576
|
+
if refit.status is Status.OK:
|
|
577
|
+
assert refit.params is not None and refit.log_likelihood is not None
|
|
578
|
+
k = sum(1 for p in refit.params.values() if not p.fixed)
|
|
579
|
+
link_aic[link] = -2.0 * refit.log_likelihood + 2.0 * k
|
|
580
|
+
else:
|
|
581
|
+
link_aic[link] = None
|
|
582
|
+
link_status[link] = refit.status
|
|
583
|
+
return link_aic, link_status
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _is_structural_zero_control(fit: Fit, cell: Cell) -> bool:
|
|
587
|
+
"""A cell is excluded from goodness of fit **only** when it is deterministic *by
|
|
588
|
+
construction* under the model, never merely because its *computed* fitted probability
|
|
589
|
+
happens to round to a boundary (a value-based rule would make ``df`` depend on the link).
|
|
590
|
+
|
|
591
|
+
The only case that is structurally deterministic in this release: a log-axis cell at severity
|
|
592
|
+
exactly ``0.0``, under ``direction="decreasing"`` (the only direction ``Fit`` accepts for
|
|
593
|
+
``model="binomial"``), when ``upper`` is *fixed* at exactly ``1.0`` -- the ``P(0) = upper``
|
|
594
|
+
rule for a zero-severity control then pins that cell's fitted probability to exactly ``1.0``
|
|
595
|
+
with zero variance, for every possible ``mu``/``s``. An *estimated* ``upper`` is never
|
|
596
|
+
exempt, even when its fitted value happens to equal ``1.0``: the control still identifies
|
|
597
|
+
``u`` in that case, so it is not deterministic *by construction* and is counted.
|
|
598
|
+
"""
|
|
599
|
+
if cell.severity != 0.0 or fit.axis.scale != "log" or fit.direction != "decreasing":
|
|
600
|
+
return False
|
|
601
|
+
assert fit.params is not None
|
|
602
|
+
upper_param = fit.params["upper"]
|
|
603
|
+
return upper_param.fixed and upper_param.value == 1.0
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def diagnose(fit: Fit) -> Diagnostics:
|
|
607
|
+
"""Model-only diagnostics for a converged fit: goodness of fit, dispersion, and link
|
|
608
|
+
comparison. Never changes ``fit`` itself (reported, never acted on) -- in particular,
|
|
609
|
+
``fit.link`` is never consulted to pick a "better" link.
|
|
610
|
+
|
|
611
|
+
Parameters
|
|
612
|
+
----------
|
|
613
|
+
fit
|
|
614
|
+
The fit to diagnose. Must have ``status is Status.OK`` (a failed fit
|
|
615
|
+
carries no model-derived numbers -- :func:`~robustmargin.fit_dose_response` itself only
|
|
616
|
+
calls this for an ``OK`` fit and leaves :attr:`~robustmargin.Fit.diagnostics` ``None``
|
|
617
|
+
otherwise).
|
|
618
|
+
|
|
619
|
+
Returns
|
|
620
|
+
-------
|
|
621
|
+
Diagnostics
|
|
622
|
+
|
|
623
|
+
Raises
|
|
624
|
+
------
|
|
625
|
+
ValueError
|
|
626
|
+
If ``fit.status`` is not :data:`~robustmargin.Status.OK`.
|
|
627
|
+
"""
|
|
628
|
+
if fit.status is not Status.OK:
|
|
629
|
+
raise ValueError(
|
|
630
|
+
f"diagnose: fit.status must be Status.OK (a failed fit carries no model-derived "
|
|
631
|
+
f"diagnostics), got {fit.status!r}"
|
|
632
|
+
)
|
|
633
|
+
assert fit.params is not None and fit.log_likelihood is not None
|
|
634
|
+
|
|
635
|
+
k = sum(1 for p in fit.params.values() if not p.fixed)
|
|
636
|
+
p_raw, q_raw = _success_and_failure_probabilities(fit)
|
|
637
|
+
# R's own binomial()$linkinv clamp, applied to each of p and (1 - p) independently -- never
|
|
638
|
+
# derived one from the other's clamped value, which would reintroduce the cancellation
|
|
639
|
+
# _curve_success_and_failure_probability exists to avoid.
|
|
640
|
+
p_clamped = np.clip(p_raw, _PROBABILITY_EPS, 1.0 - _PROBABILITY_EPS)
|
|
641
|
+
q_clamped = np.clip(q_raw, _PROBABILITY_EPS, 1.0 - _PROBABILITY_EPS)
|
|
642
|
+
|
|
643
|
+
deviance_sum = 0.0
|
|
644
|
+
pearson_sum = 0.0
|
|
645
|
+
counted_cells = 0
|
|
646
|
+
sparse_entries: list[tuple[float, float]] = []
|
|
647
|
+
for cell, p, q in zip(fit.cells, p_clamped, q_clamped, strict=True):
|
|
648
|
+
if _is_structural_zero_control(fit, cell):
|
|
649
|
+
# Deterministic under the model (P(0) = upper exactly, fixed at 1.0) -- zero
|
|
650
|
+
# variance and zero deviance by construction, not excluded because its *computed*
|
|
651
|
+
# probability happens to round to a boundary.
|
|
652
|
+
continue
|
|
653
|
+
counted_cells += 1
|
|
654
|
+
y = float(cell.successes)
|
|
655
|
+
n = float(cell.trials)
|
|
656
|
+
p_f = float(p)
|
|
657
|
+
q_f = float(q)
|
|
658
|
+
expected_successes = n * p_f
|
|
659
|
+
expected_failures = n * q_f
|
|
660
|
+
deviance_sum += 2.0 * (
|
|
661
|
+
float(xlogy(y, y / expected_successes))
|
|
662
|
+
+ float(xlogy(n - y, (n - y) / expected_failures))
|
|
663
|
+
)
|
|
664
|
+
pearson_sum += (y - expected_successes) ** 2 / (n * p_f * q_f)
|
|
665
|
+
# A side is flagged only when *both* its expected count is below the cutoff and
|
|
666
|
+
# something was actually observed there -- a small expectation with nothing observed on
|
|
667
|
+
# that side is a clean plateau, not a finding.
|
|
668
|
+
flagged_side_expecteds: list[float] = []
|
|
669
|
+
if expected_successes < _SPARSE_EXPECTED_CUTOFF and y >= 1.0:
|
|
670
|
+
flagged_side_expecteds.append(expected_successes)
|
|
671
|
+
if expected_failures < _SPARSE_EXPECTED_CUTOFF and (n - y) >= 1.0:
|
|
672
|
+
flagged_side_expecteds.append(expected_failures)
|
|
673
|
+
if flagged_side_expecteds:
|
|
674
|
+
sparse_entries.append((cell.severity, min(flagged_side_expecteds)))
|
|
675
|
+
|
|
676
|
+
df = counted_cells - k
|
|
677
|
+
warnings: list[str] = []
|
|
678
|
+
deviance: float | None
|
|
679
|
+
pearson_chi2: float | None
|
|
680
|
+
dispersion: float | None
|
|
681
|
+
if df <= 0:
|
|
682
|
+
deviance, pearson_chi2, dispersion = None, None, None
|
|
683
|
+
warnings.append(
|
|
684
|
+
f"diagnose: degrees of freedom is {df} (<= 0: {counted_cells} cell(s) counted "
|
|
685
|
+
f"minus {k} estimated parameter(s)) -- no goodness-of-fit statistics are reported"
|
|
686
|
+
)
|
|
687
|
+
else:
|
|
688
|
+
deviance = float(deviance_sum)
|
|
689
|
+
pearson_chi2 = float(pearson_sum)
|
|
690
|
+
dispersion = pearson_chi2 / df
|
|
691
|
+
if dispersion > _DISPERSION_WARNING_THRESHOLD:
|
|
692
|
+
warnings.append(
|
|
693
|
+
f"{_OVERDISPERSED_WARNING_PREFIX}dispersion (Pearson chi-square / df) is "
|
|
694
|
+
f"{dispersion!r}, exceeding {_DISPERSION_WARNING_THRESHOLD}; nothing is "
|
|
695
|
+
"rescaled and no interval changes as a result"
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
if sparse_entries:
|
|
699
|
+
# Report-only -- names only the flagged severities (an observed event against a
|
|
700
|
+
# below-cutoff expectation on that side), and the single smallest expected count among
|
|
701
|
+
# those *flagged sides only*; changes no number above.
|
|
702
|
+
severities_repr = ", ".join(repr(severity) for severity, _ in sparse_entries)
|
|
703
|
+
minimum_expected = min(count for _, count in sparse_entries)
|
|
704
|
+
warnings.append(
|
|
705
|
+
f"{_SPARSE_CELLS_WARNING_PREFIX}severity/severities {severities_repr} have an "
|
|
706
|
+
f"expected count of successes or failures below "
|
|
707
|
+
f"{_SPARSE_EXPECTED_CUTOFF!r} with at least one observed there (minimum "
|
|
708
|
+
f"{minimum_expected:.6f}); Pearson chi-square and dispersion (phi) are unreliable "
|
|
709
|
+
"there; no number here is changed as a result"
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
link_aic, link_status = _link_comparison(fit)
|
|
713
|
+
|
|
714
|
+
return Diagnostics(
|
|
715
|
+
deviance=deviance,
|
|
716
|
+
pearson_chi2=pearson_chi2,
|
|
717
|
+
df=df,
|
|
718
|
+
dispersion=dispersion,
|
|
719
|
+
link_aic=link_aic,
|
|
720
|
+
link_status=link_status,
|
|
721
|
+
warnings=tuple(warnings),
|
|
722
|
+
)
|