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,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
+ )