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,785 @@
|
|
|
1
|
+
"""Confidence intervals for a solved threshold: profile likelihood (the primary method) and the
|
|
2
|
+
delta method (the cross-check).
|
|
3
|
+
|
|
4
|
+
Both operate on the transformed axis ``theta`` -- ``log(severity)`` on a log axis, raw severity
|
|
5
|
+
on a linear axis -- and map back to the natural severity scale before returning, per
|
|
6
|
+
:func:`robustmargin.threshold.threshold`'s contract. :func:`solve_value` is the shared closed-form
|
|
7
|
+
step: ``theta_hat = mu + s * z*``, where ``z* = F^-1((upper - target) / (upper - lower))``.
|
|
8
|
+
|
|
9
|
+
**Profile likelihood.** With both asymptotes fixed, the offset trick applies
|
|
10
|
+
exactly: a no-intercept ``GLM`` on the failure rate, covariate ``t - theta``, offset ``z*``
|
|
11
|
+
(constant, since fixed asymptotes make ``z*`` fixed too). With an estimated asymptote the offset
|
|
12
|
+
trick does not apply, because ``z* = F^-1((u - P*) / (u - l))`` moves with the candidate ``(u,
|
|
13
|
+
l)`` the constrained optimiser is trying at each step (and ``P*`` itself moves with ``u`` for
|
|
14
|
+
``baseline_fraction``): the substitution ``mu = theta - s * z*(u, l)`` is folded directly into
|
|
15
|
+
the log-likelihood instead, handed to ``statsmodels.base.model.GenericLikelihoodModel`` with the
|
|
16
|
+
same ``bfgs`` -> ``newton`` sequence the estimated-asymptote fit itself uses
|
|
17
|
+
(a likelihood for a statsmodels optimiser, never a hand-rolled one). A candidate ``(u, l)`` that
|
|
18
|
+
makes ``P*`` unreachable, or an inner fit that fails outright, opens that side of the search rather
|
|
19
|
+
than returning a number (a failed fit never carries a number).
|
|
20
|
+
|
|
21
|
+
Both ``ell_hat`` and ``ell_p(theta)`` are computed with
|
|
22
|
+
``robustmargin.models._full_binomial_log_likelihood`` at natural-scale parameters -- the inner GLM
|
|
23
|
+
or ``GenericLikelihoodModel`` fit is used only as an optimiser for the *other* parameters at a
|
|
24
|
+
fixed ``theta``, never trusted for the log-likelihood value itself, so the two numbers a root is
|
|
25
|
+
found between are always computed the same way (GLM deviance, ``Fit.log_likelihood`` and the
|
|
26
|
+
log-axis control row otherwise do not agree term for term).
|
|
27
|
+
|
|
28
|
+
**Delta method.** ``theta = mu + s * z*``, so ``d(theta)/d(mu) = 1``, ``d(theta)/d(s) = z*``,
|
|
29
|
+
and -- when an asymptote is estimated -- ``d(theta)/d(u) = s * (1 / f(z*)) * d(q)/d(u)``
|
|
30
|
+
(mirrored for ``l``), where ``q = (u - P*) / (u - l)`` and ``f`` is the link's density. The
|
|
31
|
+
variance of ``theta`` is computed on the transformed axis directly with ``fit.covariance``
|
|
32
|
+
(observed information), then converted to a *log-scale* standard error even
|
|
33
|
+
on a linear axis (``sigma_log = sigma_theta / value``) before exponentiating around ``value`` --
|
|
34
|
+
this is what keeps the interval positive and equivariant under ``x -> c * x`` regardless of
|
|
35
|
+
axis.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import math
|
|
41
|
+
import warnings
|
|
42
|
+
from collections.abc import Callable
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
from numpy.typing import NDArray
|
|
47
|
+
from scipy.optimize import brentq
|
|
48
|
+
from scipy.special import expit
|
|
49
|
+
from scipy.special import logit as _logit
|
|
50
|
+
from scipy.stats import chi2, norm
|
|
51
|
+
from statsmodels.base.model import GenericLikelihoodModel
|
|
52
|
+
from statsmodels.genmod import families
|
|
53
|
+
from statsmodels.genmod.generalized_linear_model import GLM
|
|
54
|
+
from statsmodels.tools.sm_exceptions import PerfectSeparationWarning
|
|
55
|
+
|
|
56
|
+
from robustmargin.models import (
|
|
57
|
+
_STATSMODELS_LINKS,
|
|
58
|
+
Fit,
|
|
59
|
+
_cells_to_arrays,
|
|
60
|
+
_full_binomial_log_likelihood,
|
|
61
|
+
_transform,
|
|
62
|
+
)
|
|
63
|
+
from robustmargin.types import Censoring, Definition, _check_finite_number
|
|
64
|
+
|
|
65
|
+
__all__ = ["IntervalResult", "delta_interval", "profile_interval", "solve_value"]
|
|
66
|
+
|
|
67
|
+
# The profile search brackets outward from theta_hat to this limit before
|
|
68
|
+
# giving up and reporting that side open. Log axis: additive in log(x); linear axis:
|
|
69
|
+
# multiplicative in x (an additive log(100) on a linear axis would be unit-dependent).
|
|
70
|
+
_SEARCH_MULTIPLE = 100.0
|
|
71
|
+
|
|
72
|
+
# `exp` of anything beyond this overflows a float64 (`math.exp(709.8)` is the last finite
|
|
73
|
+
# value). A delta half-width at or near it means the interval is unbounded in practice, so
|
|
74
|
+
# it is reported as no bounds rather than as a number that happens to fit in a float.
|
|
75
|
+
_MAX_LOG_HALF_WIDTH = 700.0
|
|
76
|
+
_MAX_BRACKET_STEPS = 200
|
|
77
|
+
_UNREACHABLE_LOGLIKELIHOOD = -1.0e12
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, kw_only=True)
|
|
81
|
+
class IntervalResult:
|
|
82
|
+
"""The result of :func:`profile_interval` or :func:`delta_interval`: severity-scale
|
|
83
|
+
endpoints, already mapped back from the transformed axis.
|
|
84
|
+
|
|
85
|
+
Attributes
|
|
86
|
+
----------
|
|
87
|
+
lo, hi
|
|
88
|
+
The interval's endpoints on the natural severity scale. ``None`` exactly on the side
|
|
89
|
+
that ``censoring`` reports open.
|
|
90
|
+
censoring
|
|
91
|
+
:data:`~robustmargin.Censoring.NONE`, :data:`~robustmargin.Censoring.OPEN_UPPER` or
|
|
92
|
+
:data:`~robustmargin.Censoring.OPEN_LOWER`.
|
|
93
|
+
warnings
|
|
94
|
+
Free-text notes (for example, an inner fit that failed while stepping outward).
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
lo: float | None
|
|
98
|
+
hi: float | None
|
|
99
|
+
censoring: Censoring
|
|
100
|
+
warnings: tuple[str, ...] = ()
|
|
101
|
+
|
|
102
|
+
def __post_init__(self) -> None:
|
|
103
|
+
object.__setattr__(self, "warnings", tuple(self.warnings))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------------------------------
|
|
107
|
+
# Shared: the link's inverse CDF and density, z*, and the closed-form solve.
|
|
108
|
+
# ------------------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _link_inverse_cdf(q: float, link: str) -> float:
|
|
112
|
+
if link == "probit":
|
|
113
|
+
return float(norm.ppf(q))
|
|
114
|
+
if link == "logit":
|
|
115
|
+
return float(_logit(q))
|
|
116
|
+
return float(math.log(-math.log(1.0 - q))) # cloglog: F(z) = 1 - exp(-exp(z))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _link_density(z: float, link: str) -> float:
|
|
120
|
+
if link == "probit":
|
|
121
|
+
return float(norm.pdf(z))
|
|
122
|
+
if link == "logit":
|
|
123
|
+
p = float(expit(z))
|
|
124
|
+
return p * (1.0 - p)
|
|
125
|
+
return float(math.exp(z - math.exp(z))) # cloglog: F'(z) = exp(z - exp(z))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _z_star(link: str, *, upper: float, lower: float, target: float) -> float | None:
|
|
129
|
+
"""``z* = F^-1((upper - target) / (upper - lower))``, or ``None`` when ``target`` is not
|
|
130
|
+
strictly inside ``(lower, upper)`` (unreachable given these asymptotes)."""
|
|
131
|
+
if not (lower < target < upper):
|
|
132
|
+
return None
|
|
133
|
+
q = (upper - target) / (upper - lower)
|
|
134
|
+
return _link_inverse_cdf(q, link)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _target_for(definition: Definition, *, upper: float, lower: float) -> float:
|
|
138
|
+
"""The target performance, as a function of a *candidate* ``(upper, lower)`` -- used by
|
|
139
|
+
the estimated-asymptote profile path, where the candidate asymptotes move at every inner
|
|
140
|
+
evaluation. ``absolute`` does not depend on them at all.
|
|
141
|
+
"""
|
|
142
|
+
if definition.kind == "absolute":
|
|
143
|
+
return definition.value
|
|
144
|
+
if definition.kind == "baseline_fraction":
|
|
145
|
+
return definition.value * upper
|
|
146
|
+
return upper - definition.value * (upper - lower) # relative
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _dq_du_dl(definition: Definition, *, upper: float, lower: float) -> tuple[float, float]:
|
|
150
|
+
"""``d(q)/d(upper)`` and ``d(q)/d(lower)`` for ``q = (upper - P*) / (upper - lower)``, per
|
|
151
|
+
definition kind (this module's docstring)."""
|
|
152
|
+
spread = upper - lower
|
|
153
|
+
if definition.kind == "absolute":
|
|
154
|
+
a = definition.value
|
|
155
|
+
return (a - lower) / spread**2, (upper - a) / spread**2
|
|
156
|
+
if definition.kind == "baseline_fraction":
|
|
157
|
+
p = definition.value
|
|
158
|
+
return -(1.0 - p) * lower / spread**2, (1.0 - p) * upper / spread**2
|
|
159
|
+
return 0.0, 0.0 # relative: q = p, independent of (upper, lower)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _to_natural(theta: float, *, scale: str) -> float:
|
|
163
|
+
return math.exp(theta) if scale == "log" else theta
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def solve_value(fit: Fit, *, target: float) -> tuple[float, float]:
|
|
167
|
+
"""The closed-form threshold, from the fit's own point estimates.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
fit
|
|
172
|
+
A ``status is Status.OK`` fit.
|
|
173
|
+
target
|
|
174
|
+
The already-resolved target performance ``P*``. Must satisfy
|
|
175
|
+
``fit.params["lower"].value < target < fit.params["upper"].value`` -- callers check
|
|
176
|
+
reachability first (:func:`robustmargin.censoring.classify`'s ``UNREACHABLE`` step).
|
|
177
|
+
|
|
178
|
+
Returns
|
|
179
|
+
-------
|
|
180
|
+
tuple[float, float]
|
|
181
|
+
``(theta_hat, value)``: ``theta_hat`` on the transformed axis, ``value`` on the natural
|
|
182
|
+
severity scale.
|
|
183
|
+
"""
|
|
184
|
+
assert fit.params is not None
|
|
185
|
+
mu = fit.params["mu"].value
|
|
186
|
+
s = fit.params["s"].value
|
|
187
|
+
upper = fit.params["upper"].value
|
|
188
|
+
lower = fit.params["lower"].value
|
|
189
|
+
z_star = _z_star(fit.link, upper=upper, lower=lower, target=target)
|
|
190
|
+
assert z_star is not None, "solve_value: target must be reachable; the caller must check first"
|
|
191
|
+
theta_hat = mu + s * z_star
|
|
192
|
+
return theta_hat, _to_natural(theta_hat, scale=fit.axis.scale)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _theta_variance(fit: Fit, *, definition: Definition, target: float) -> float:
|
|
196
|
+
"""``Var(theta)`` on the *transformed* axis (``log(severity)`` on a log axis, raw severity on
|
|
197
|
+
a linear one) -- the delta-method gradient assembly this module's docstring describes,
|
|
198
|
+
factored out of :func:`delta_interval` so :mod:`robustmargin.ratio` can reuse the exact same
|
|
199
|
+
computation for its Fieller and log-delta variance inputs instead of reconstructing the
|
|
200
|
+
gradient a second time, which would risk the two drifting apart. Not converted to a
|
|
201
|
+
natural-scale or log-scale standard error here -- callers each do that conversion themselves,
|
|
202
|
+
since Fieller and a single threshold's own delta interval need it on different scales
|
|
203
|
+
(:mod:`robustmargin.ratio`'s module docstring).
|
|
204
|
+
|
|
205
|
+
Private: not part of the public API. Callable across modules (``robustmargin.ratio`` imports it
|
|
206
|
+
directly) without being exported from either module's ``__all__``.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
fit
|
|
211
|
+
A ``status is Status.OK`` fit.
|
|
212
|
+
definition
|
|
213
|
+
The :class:`~robustmargin.Definition` the threshold solves for (needed for ``d(q)/d(u)``
|
|
214
|
+
and ``d(q)/d(l)`` when an asymptote is estimated).
|
|
215
|
+
target
|
|
216
|
+
The already-resolved target performance.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
float
|
|
221
|
+
``gradient @ covariance @ gradient``, not yet clamped to ``>= 0`` -- callers do that
|
|
222
|
+
(mirrors this module's own ``max(var_theta, 0.0)`` usage below).
|
|
223
|
+
"""
|
|
224
|
+
assert fit.params is not None and fit.covariance is not None
|
|
225
|
+
upper = fit.params["upper"].value
|
|
226
|
+
lower = fit.params["lower"].value
|
|
227
|
+
z_star = _z_star(fit.link, upper=upper, lower=lower, target=target)
|
|
228
|
+
assert z_star is not None, "the caller must have already checked reachability"
|
|
229
|
+
|
|
230
|
+
names = fit.covariance.names
|
|
231
|
+
gradient_by_name = {"mu": 1.0, "s": z_star}
|
|
232
|
+
if "upper" in names or "lower" in names:
|
|
233
|
+
density = _link_density(z_star, fit.link)
|
|
234
|
+
dq_du, dq_dl = _dq_du_dl(definition, upper=upper, lower=lower)
|
|
235
|
+
s = fit.params["s"].value
|
|
236
|
+
if "upper" in names:
|
|
237
|
+
gradient_by_name["upper"] = s * dq_du / density
|
|
238
|
+
if "lower" in names:
|
|
239
|
+
gradient_by_name["lower"] = s * dq_dl / density
|
|
240
|
+
|
|
241
|
+
gradient = np.array([gradient_by_name[name] for name in names], dtype=np.float64)
|
|
242
|
+
matrix = np.array([[float(v) for v in row] for row in fit.covariance.matrix], dtype=np.float64)
|
|
243
|
+
return float(gradient @ matrix @ gradient)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _llf_hat(fit: Fit) -> float:
|
|
247
|
+
assert fit.params is not None
|
|
248
|
+
severity, successes, trials = _cells_to_arrays(fit.cells)
|
|
249
|
+
return _full_binomial_log_likelihood(
|
|
250
|
+
severity,
|
|
251
|
+
successes,
|
|
252
|
+
trials,
|
|
253
|
+
mu=fit.params["mu"].value,
|
|
254
|
+
s=fit.params["s"].value,
|
|
255
|
+
upper=fit.params["upper"].value,
|
|
256
|
+
lower=fit.params["lower"].value,
|
|
257
|
+
scale=fit.axis.scale,
|
|
258
|
+
link=fit.link,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _search_limits(fit: Fit) -> tuple[float, float]:
|
|
263
|
+
"""``(lower_limit, upper_limit)`` on theta's own scale: additive ``log(_SEARCH_MULTIPLE)``
|
|
264
|
+
beyond the tested (nonzero, on a log axis) range for a log axis; multiplicative by
|
|
265
|
+
``_SEARCH_MULTIPLE`` for a linear one."""
|
|
266
|
+
severities = [c.severity for c in fit.cells]
|
|
267
|
+
if fit.axis.scale == "log":
|
|
268
|
+
nonzero = [s for s in severities if s > 0.0]
|
|
269
|
+
transformed = [math.log(s) for s in nonzero]
|
|
270
|
+
return min(transformed) - math.log(_SEARCH_MULTIPLE), max(transformed) + math.log(
|
|
271
|
+
_SEARCH_MULTIPLE
|
|
272
|
+
)
|
|
273
|
+
positive = [s for s in severities if s > 0.0]
|
|
274
|
+
x_min_pos = min(positive) if positive else 1.0e-6
|
|
275
|
+
return x_min_pos / _SEARCH_MULTIPLE, max(severities) * _SEARCH_MULTIPLE
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ------------------------------------------------------------------------------------------
|
|
279
|
+
# Profile likelihood: root bracketing, shared by the fixed and estimated-asymptote paths.
|
|
280
|
+
# ------------------------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _bracket_root(
|
|
284
|
+
g: Callable[[float], float | None], theta_hat: float, limit: float, direction: float
|
|
285
|
+
) -> tuple[str, float] | tuple[str, float, float]:
|
|
286
|
+
"""Step outward from ``theta_hat`` toward ``limit`` (``direction`` is ``+1.0`` or ``-1.0``)
|
|
287
|
+
looking for ``g``'s sign change (``g(theta_hat) < 0``, by the caller's own precondition).
|
|
288
|
+
|
|
289
|
+
Returns ``("bracket", a, b)`` with ``g(a) < 0 <= g(b)``, ``("open_failed", last_theta)`` if
|
|
290
|
+
the inner fit failed at some ``theta`` before a sign change was found, or
|
|
291
|
+
``("open_limit", limit)`` if the search limit was reached with no sign change.
|
|
292
|
+
"""
|
|
293
|
+
g0 = g(theta_hat)
|
|
294
|
+
if g0 is None:
|
|
295
|
+
return ("open_failed", theta_hat)
|
|
296
|
+
span = abs(limit - theta_hat)
|
|
297
|
+
if span <= 0.0:
|
|
298
|
+
return ("open_limit", limit)
|
|
299
|
+
step = max(span * 1.0e-4, 1.0e-8)
|
|
300
|
+
prev_theta, prev_g = theta_hat, g0
|
|
301
|
+
theta = theta_hat
|
|
302
|
+
for _ in range(_MAX_BRACKET_STEPS):
|
|
303
|
+
step *= 1.7
|
|
304
|
+
theta = theta_hat + direction * step
|
|
305
|
+
reached_limit = (direction > 0.0 and theta >= limit) or (direction < 0.0 and theta <= limit)
|
|
306
|
+
if reached_limit:
|
|
307
|
+
theta = limit
|
|
308
|
+
g_theta = g(theta)
|
|
309
|
+
if g_theta is None:
|
|
310
|
+
return ("open_failed", prev_theta)
|
|
311
|
+
if prev_g < 0.0 <= g_theta:
|
|
312
|
+
return ("bracket", prev_theta, theta)
|
|
313
|
+
if reached_limit:
|
|
314
|
+
return ("open_limit", theta)
|
|
315
|
+
prev_theta, prev_g = theta, g_theta
|
|
316
|
+
return ("open_limit", theta)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _solve_profile_bracket(
|
|
320
|
+
g: Callable[[float], float | None],
|
|
321
|
+
*,
|
|
322
|
+
theta_hat: float,
|
|
323
|
+
lower_limit: float,
|
|
324
|
+
upper_limit: float,
|
|
325
|
+
scale: str,
|
|
326
|
+
) -> IntervalResult:
|
|
327
|
+
collected_warnings: list[str] = []
|
|
328
|
+
|
|
329
|
+
def _side(direction: float, limit: float, label: str) -> float | None:
|
|
330
|
+
outcome = _bracket_root(g, theta_hat, limit, direction)
|
|
331
|
+
if outcome[0] == "open_failed":
|
|
332
|
+
collected_warnings.append(
|
|
333
|
+
"intervals.profile_interval: the inner fit failed while stepping toward the "
|
|
334
|
+
f"{label} search limit (theta={outcome[1]!r}); {label} side left open"
|
|
335
|
+
)
|
|
336
|
+
return None
|
|
337
|
+
if outcome[0] == "open_limit":
|
|
338
|
+
collected_warnings.append(
|
|
339
|
+
"intervals.profile_interval: the profile deviance did not cross the chi-square "
|
|
340
|
+
f"target before the {label} search limit (theta={outcome[1]!r}); {label} side "
|
|
341
|
+
"left open"
|
|
342
|
+
)
|
|
343
|
+
return None
|
|
344
|
+
if len(outcome) != 3: # pragma: no cover - narrowed by the checks above
|
|
345
|
+
raise AssertionError(f"unexpected bracket outcome {outcome!r}")
|
|
346
|
+
_, a, b = outcome
|
|
347
|
+
root = brentq(g, a, b, xtol=1.0e-10, rtol=1.0e-12, maxiter=200)
|
|
348
|
+
return _to_natural(root, scale=scale)
|
|
349
|
+
|
|
350
|
+
hi = _side(1.0, upper_limit, "upper")
|
|
351
|
+
lo = _side(-1.0, lower_limit, "lower")
|
|
352
|
+
|
|
353
|
+
if hi is None and lo is None:
|
|
354
|
+
# Neither side crossed the chi-square target: the design does not constrain the
|
|
355
|
+
# threshold. v1 has no `Censoring` member for "open on both sides", so the estimate is
|
|
356
|
+
# reported with no bounds and `censoring=NONE`, with the reason already collected in
|
|
357
|
+
# `warnings`. Reporting a bound at the search limit would be inventing one.
|
|
358
|
+
return IntervalResult(
|
|
359
|
+
lo=None, hi=None, censoring=Censoring.NONE, warnings=tuple(collected_warnings)
|
|
360
|
+
)
|
|
361
|
+
if hi is None:
|
|
362
|
+
return IntervalResult(
|
|
363
|
+
lo=lo, hi=None, censoring=Censoring.OPEN_UPPER, warnings=tuple(collected_warnings)
|
|
364
|
+
)
|
|
365
|
+
if lo is None:
|
|
366
|
+
return IntervalResult(
|
|
367
|
+
lo=None, hi=hi, censoring=Censoring.OPEN_LOWER, warnings=tuple(collected_warnings)
|
|
368
|
+
)
|
|
369
|
+
return IntervalResult(
|
|
370
|
+
lo=lo, hi=hi, censoring=Censoring.NONE, warnings=tuple(collected_warnings)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ------------------------------------------------------------------------------------------
|
|
375
|
+
# Profile likelihood: fixed-asymptote path (the no-intercept offset-trick GLM).
|
|
376
|
+
# ------------------------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _fixed_profile_llf(
|
|
380
|
+
theta: float,
|
|
381
|
+
*,
|
|
382
|
+
nonzero_severity: NDArray[np.float64],
|
|
383
|
+
nonzero_successes: NDArray[np.float64],
|
|
384
|
+
nonzero_trials: NDArray[np.float64],
|
|
385
|
+
all_severity: NDArray[np.float64],
|
|
386
|
+
all_successes: NDArray[np.float64],
|
|
387
|
+
all_trials: NDArray[np.float64],
|
|
388
|
+
upper: float,
|
|
389
|
+
lower: float,
|
|
390
|
+
link: str,
|
|
391
|
+
scale: str,
|
|
392
|
+
z_star: float,
|
|
393
|
+
) -> float | None:
|
|
394
|
+
"""``ell_p(theta)`` for the fixed-asymptote path: fit the no-intercept offset GLM at this
|
|
395
|
+
``theta`` (an *optimiser*, per this module's docstring), then evaluate the full likelihood
|
|
396
|
+
at the resulting ``(mu, s)``. Returns ``None`` (an inner-fit failure) when the GLM does not
|
|
397
|
+
converge or its fitted slope is non-positive -- a wrong-sign slope means the profile value
|
|
398
|
+
is the flat-curve boundary, so that side never closes there.
|
|
399
|
+
"""
|
|
400
|
+
covariate = (_transform(nonzero_severity, scale=scale) - theta).reshape(-1, 1)
|
|
401
|
+
failures = nonzero_trials - nonzero_successes
|
|
402
|
+
endog = failures / nonzero_trials
|
|
403
|
+
offset = np.full(nonzero_severity.shape, z_star)
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
with warnings.catch_warnings():
|
|
407
|
+
# On grouped binomial data statsmodels' PerfectSeparationWarning fires on any
|
|
408
|
+
# exact fit, so it carries no information here and maps to no status.
|
|
409
|
+
# Separation is decided by the pre-fit overlap check, before this function runs.
|
|
410
|
+
warnings.filterwarnings("ignore", category=PerfectSeparationWarning)
|
|
411
|
+
model = GLM(
|
|
412
|
+
endog,
|
|
413
|
+
covariate,
|
|
414
|
+
offset=offset,
|
|
415
|
+
family=families.Binomial(link=_STATSMODELS_LINKS[link]),
|
|
416
|
+
var_weights=nonzero_trials,
|
|
417
|
+
)
|
|
418
|
+
result = model.fit()
|
|
419
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
420
|
+
return None
|
|
421
|
+
if not bool(result.converged):
|
|
422
|
+
return None
|
|
423
|
+
beta = float(result.params[0])
|
|
424
|
+
if not beta > 0.0:
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
s = 1.0 / beta
|
|
428
|
+
mu = theta - s * z_star
|
|
429
|
+
return _full_binomial_log_likelihood(
|
|
430
|
+
all_severity,
|
|
431
|
+
all_successes,
|
|
432
|
+
all_trials,
|
|
433
|
+
mu=mu,
|
|
434
|
+
s=s,
|
|
435
|
+
upper=upper,
|
|
436
|
+
lower=lower,
|
|
437
|
+
scale=scale,
|
|
438
|
+
link=link,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _profile_interval_fixed(
|
|
443
|
+
fit: Fit, *, target: float, level: float, theta_hat: float
|
|
444
|
+
) -> IntervalResult:
|
|
445
|
+
assert fit.params is not None
|
|
446
|
+
upper = fit.params["upper"].value
|
|
447
|
+
lower = fit.params["lower"].value
|
|
448
|
+
z_star = _z_star(fit.link, upper=upper, lower=lower, target=target)
|
|
449
|
+
assert z_star is not None, "the caller must have already checked reachability"
|
|
450
|
+
|
|
451
|
+
all_severity, all_successes, all_trials = _cells_to_arrays(fit.cells)
|
|
452
|
+
nonzero_cells = tuple(
|
|
453
|
+
c for c in fit.cells if not (fit.axis.scale == "log" and c.severity == 0.0)
|
|
454
|
+
)
|
|
455
|
+
nz_severity, nz_successes, nz_trials = _cells_to_arrays(nonzero_cells)
|
|
456
|
+
|
|
457
|
+
llf_hat = _llf_hat(fit)
|
|
458
|
+
chi2_target = float(chi2.ppf(level, 1))
|
|
459
|
+
|
|
460
|
+
def g(theta: float) -> float | None:
|
|
461
|
+
llf_p = _fixed_profile_llf(
|
|
462
|
+
theta,
|
|
463
|
+
nonzero_severity=nz_severity,
|
|
464
|
+
nonzero_successes=nz_successes,
|
|
465
|
+
nonzero_trials=nz_trials,
|
|
466
|
+
all_severity=all_severity,
|
|
467
|
+
all_successes=all_successes,
|
|
468
|
+
all_trials=all_trials,
|
|
469
|
+
upper=upper,
|
|
470
|
+
lower=lower,
|
|
471
|
+
link=fit.link,
|
|
472
|
+
scale=fit.axis.scale,
|
|
473
|
+
z_star=z_star,
|
|
474
|
+
)
|
|
475
|
+
if llf_p is None:
|
|
476
|
+
return None
|
|
477
|
+
return 2.0 * (llf_hat - llf_p) - chi2_target
|
|
478
|
+
|
|
479
|
+
g0 = g(theta_hat)
|
|
480
|
+
if g0 is None or not g0 < 0.0:
|
|
481
|
+
raise ValueError(
|
|
482
|
+
"intervals.profile_interval: the profile deviance at theta_hat did not come out "
|
|
483
|
+
f"below the chi-square target (g(theta_hat)={g0!r}); the inner optimiser may have "
|
|
484
|
+
"beaten the reported MLE"
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
lower_limit, upper_limit = _search_limits(fit)
|
|
488
|
+
return _solve_profile_bracket(
|
|
489
|
+
g,
|
|
490
|
+
theta_hat=theta_hat,
|
|
491
|
+
lower_limit=lower_limit,
|
|
492
|
+
upper_limit=upper_limit,
|
|
493
|
+
scale=fit.axis.scale,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# ------------------------------------------------------------------------------------------
|
|
498
|
+
# Profile likelihood: estimated-asymptote path (mu-substituted GenericLikelihoodModel).
|
|
499
|
+
# ------------------------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@dataclass(frozen=True)
|
|
503
|
+
class _MuSubstitutedLogLikelihood:
|
|
504
|
+
"""``GenericLikelihoodModel``'s ``loglike=`` callable for a fixed candidate ``theta``: the
|
|
505
|
+
free parameters are ``log(s)`` plus whichever asymptote logits the original fit estimated
|
|
506
|
+
(same parametrisation as ``models._unpack_optimizer_params``, with ``mu`` removed -- it is
|
|
507
|
+
substituted at every evaluation from the *candidate* asymptotes, since ``z*`` moves with
|
|
508
|
+
them here (this module's docstring)). Returns a very negative constant, never raises, when a
|
|
509
|
+
candidate makes the target unreachable or yields a non-finite ``mu``, so the optimiser is
|
|
510
|
+
steered away from that region rather than failing outright (mirrors
|
|
511
|
+
``models._GenericLogLikelihood``'s own robustness).
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
theta: float
|
|
515
|
+
definition: Definition
|
|
516
|
+
severity: NDArray[np.float64]
|
|
517
|
+
successes: NDArray[np.float64]
|
|
518
|
+
trials: NDArray[np.float64]
|
|
519
|
+
scale: str
|
|
520
|
+
link: str
|
|
521
|
+
estimate_lower: bool
|
|
522
|
+
estimate_upper: bool
|
|
523
|
+
lower_value: float
|
|
524
|
+
upper_value: float
|
|
525
|
+
|
|
526
|
+
def __call__(self, params: NDArray[np.float64]) -> float:
|
|
527
|
+
s = float(np.exp(params[0]))
|
|
528
|
+
idx = 1
|
|
529
|
+
if self.estimate_lower:
|
|
530
|
+
a = float(params[idx])
|
|
531
|
+
idx += 1
|
|
532
|
+
lower = float(expit(a)) if self.estimate_upper else float(self.upper_value * expit(a))
|
|
533
|
+
else:
|
|
534
|
+
lower = self.lower_value
|
|
535
|
+
if self.estimate_upper:
|
|
536
|
+
b = float(params[idx])
|
|
537
|
+
upper = float(lower + (1.0 - lower) * expit(b))
|
|
538
|
+
else:
|
|
539
|
+
upper = self.upper_value
|
|
540
|
+
|
|
541
|
+
if not math.isfinite(lower) or not math.isfinite(upper) or not lower < upper:
|
|
542
|
+
return _UNREACHABLE_LOGLIKELIHOOD
|
|
543
|
+
target = _target_for(self.definition, upper=upper, lower=lower)
|
|
544
|
+
z_star = _z_star(self.link, upper=upper, lower=lower, target=target)
|
|
545
|
+
if z_star is None or not math.isfinite(z_star):
|
|
546
|
+
return _UNREACHABLE_LOGLIKELIHOOD
|
|
547
|
+
mu = self.theta - s * z_star
|
|
548
|
+
if not math.isfinite(mu):
|
|
549
|
+
return _UNREACHABLE_LOGLIKELIHOOD
|
|
550
|
+
return _full_binomial_log_likelihood(
|
|
551
|
+
self.severity,
|
|
552
|
+
self.successes,
|
|
553
|
+
self.trials,
|
|
554
|
+
mu=mu,
|
|
555
|
+
s=s,
|
|
556
|
+
upper=upper,
|
|
557
|
+
lower=lower,
|
|
558
|
+
scale=self.scale,
|
|
559
|
+
link=self.link,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _optimizer_start_from_fit(fit: Fit) -> NDArray[np.float64]:
|
|
564
|
+
"""A starting point for the constrained (mu-substituted) optimiser, from the *fit's own*
|
|
565
|
+
converged natural-scale parameters -- a good start, since the profile is centred on it, but
|
|
566
|
+
only a start (as for the outer fit, a starting point is never itself reported)."""
|
|
567
|
+
assert fit.params is not None
|
|
568
|
+
s = fit.params["s"].value
|
|
569
|
+
lower_param = fit.params["lower"]
|
|
570
|
+
upper_param = fit.params["upper"]
|
|
571
|
+
eps = 1.0e-6
|
|
572
|
+
start = [math.log(s)]
|
|
573
|
+
if not lower_param.fixed:
|
|
574
|
+
if not upper_param.fixed:
|
|
575
|
+
a0 = float(_logit(np.clip(lower_param.value, eps, 1.0 - eps)))
|
|
576
|
+
else:
|
|
577
|
+
a0 = float(_logit(np.clip(lower_param.value / upper_param.value, eps, 1.0 - eps)))
|
|
578
|
+
start.append(a0)
|
|
579
|
+
if not upper_param.fixed:
|
|
580
|
+
denominator = max(1.0 - lower_param.value, 1.0e-6)
|
|
581
|
+
fraction = float(
|
|
582
|
+
np.clip((upper_param.value - lower_param.value) / denominator, eps, 1.0 - eps)
|
|
583
|
+
)
|
|
584
|
+
start.append(float(_logit(fraction)))
|
|
585
|
+
return np.array(start, dtype=np.float64)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _estimated_profile_llf(theta: float, *, fit: Fit, definition: Definition) -> float | None:
|
|
589
|
+
assert fit.params is not None
|
|
590
|
+
severity, successes, trials = _cells_to_arrays(fit.cells)
|
|
591
|
+
lower_param = fit.params["lower"]
|
|
592
|
+
upper_param = fit.params["upper"]
|
|
593
|
+
loglike_fn = _MuSubstitutedLogLikelihood(
|
|
594
|
+
theta=theta,
|
|
595
|
+
definition=definition,
|
|
596
|
+
severity=severity,
|
|
597
|
+
successes=successes,
|
|
598
|
+
trials=trials,
|
|
599
|
+
scale=fit.axis.scale,
|
|
600
|
+
link=fit.link,
|
|
601
|
+
estimate_lower=not lower_param.fixed,
|
|
602
|
+
estimate_upper=not upper_param.fixed,
|
|
603
|
+
lower_value=lower_param.value if lower_param.fixed else math.nan,
|
|
604
|
+
upper_value=upper_param.value if upper_param.fixed else math.nan,
|
|
605
|
+
)
|
|
606
|
+
start = _optimizer_start_from_fit(fit)
|
|
607
|
+
exog = np.ones((severity.size, start.size))
|
|
608
|
+
model = GenericLikelihoodModel(successes, exog, loglike=loglike_fn)
|
|
609
|
+
try:
|
|
610
|
+
with warnings.catch_warnings():
|
|
611
|
+
warnings.simplefilter("ignore")
|
|
612
|
+
bfgs_result = model.fit(
|
|
613
|
+
start_params=start, method="bfgs", disp=False, maxiter=500, gtol=1e-8
|
|
614
|
+
)
|
|
615
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
616
|
+
return None
|
|
617
|
+
llf = float(bfgs_result.llf)
|
|
618
|
+
|
|
619
|
+
# The Newton polish is an improvement, not a requirement. Its own failure modes -- a
|
|
620
|
+
# singular Hessian at an outlying theta, or a downhill step -- must not discard the usable
|
|
621
|
+
# BFGS profile value underneath it: `ell_p` too low inflates `2 * (llf_hat - ell_p)`, which
|
|
622
|
+
# moves `g`'s crossing closer to `theta_hat` and ships a silently *too narrow* interval.
|
|
623
|
+
# Taking the maximum is the conservative direction, and mirrors the guard `models.py`
|
|
624
|
+
# already applies to the outer fit.
|
|
625
|
+
try:
|
|
626
|
+
with warnings.catch_warnings():
|
|
627
|
+
warnings.simplefilter("ignore")
|
|
628
|
+
polished = model.fit(
|
|
629
|
+
start_params=np.asarray(bfgs_result.params, dtype=np.float64),
|
|
630
|
+
method="newton",
|
|
631
|
+
disp=False,
|
|
632
|
+
maxiter=100,
|
|
633
|
+
tol=1e-8,
|
|
634
|
+
)
|
|
635
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
636
|
+
polished = None
|
|
637
|
+
if polished is not None:
|
|
638
|
+
polished_llf = float(polished.llf)
|
|
639
|
+
if math.isfinite(polished_llf):
|
|
640
|
+
llf = max(llf, polished_llf)
|
|
641
|
+
if not math.isfinite(llf) or llf <= _UNREACHABLE_LOGLIKELIHOOD / 2.0:
|
|
642
|
+
return None
|
|
643
|
+
return llf
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _profile_interval_estimated(
|
|
647
|
+
fit: Fit, *, definition: Definition, level: float, theta_hat: float
|
|
648
|
+
) -> IntervalResult:
|
|
649
|
+
llf_hat = _llf_hat(fit)
|
|
650
|
+
chi2_target = float(chi2.ppf(level, 1))
|
|
651
|
+
|
|
652
|
+
def g(theta: float) -> float | None:
|
|
653
|
+
llf_p = _estimated_profile_llf(theta, fit=fit, definition=definition)
|
|
654
|
+
if llf_p is None:
|
|
655
|
+
return None
|
|
656
|
+
return 2.0 * (llf_hat - llf_p) - chi2_target
|
|
657
|
+
|
|
658
|
+
g0 = g(theta_hat)
|
|
659
|
+
if g0 is None or not g0 < 0.0:
|
|
660
|
+
raise ValueError(
|
|
661
|
+
"intervals.profile_interval: the estimated-asymptote profile deviance at theta_hat "
|
|
662
|
+
f"did not come out below the chi-square target (g(theta_hat)={g0!r}); the inner "
|
|
663
|
+
"optimiser may have beaten the reported MLE"
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
lower_limit, upper_limit = _search_limits(fit)
|
|
667
|
+
return _solve_profile_bracket(
|
|
668
|
+
g,
|
|
669
|
+
theta_hat=theta_hat,
|
|
670
|
+
lower_limit=lower_limit,
|
|
671
|
+
upper_limit=upper_limit,
|
|
672
|
+
scale=fit.axis.scale,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def profile_interval(
|
|
677
|
+
fit: Fit, *, definition: Definition, target: float, level: float, theta_hat: float
|
|
678
|
+
) -> IntervalResult:
|
|
679
|
+
"""The profile-likelihood interval for a solved threshold (the primary method).
|
|
680
|
+
|
|
681
|
+
Parameters
|
|
682
|
+
----------
|
|
683
|
+
fit
|
|
684
|
+
A ``status is Status.OK`` fit.
|
|
685
|
+
definition
|
|
686
|
+
The :class:`~robustmargin.Definition` the threshold solves for -- only used by the
|
|
687
|
+
estimated-asymptote path, where the candidate asymptotes at each inner evaluation move
|
|
688
|
+
both ``z*`` and (for ``baseline_fraction``) the target itself.
|
|
689
|
+
target
|
|
690
|
+
The already-resolved target performance at the fit's own point estimates.
|
|
691
|
+
level
|
|
692
|
+
The confidence level, strictly between 0 and 1.
|
|
693
|
+
theta_hat
|
|
694
|
+
The point estimate on the transformed axis (:func:`solve_value`'s first return value).
|
|
695
|
+
|
|
696
|
+
Returns
|
|
697
|
+
-------
|
|
698
|
+
IntervalResult
|
|
699
|
+
"""
|
|
700
|
+
_check_finite_number(level, label="profile_interval level")
|
|
701
|
+
assert fit.params is not None
|
|
702
|
+
# Dispatch on the asymptote *values*, not on their `fixed` flags. The offset trick models
|
|
703
|
+
# the raw failure rate, `P(failure) = F(z)`, which is an identity only at `u = 1, l = 0`.
|
|
704
|
+
# For any other fixed pair the modelled quantity is `(u - P) / (u - l)`, so the inner GLM
|
|
705
|
+
# maximises the wrong objective: `ell_p` is then evaluated with the right likelihood at the
|
|
706
|
+
# wrong parameters, understating it and shipping a too-narrow interval.
|
|
707
|
+
# This mirrors `models.py`'s own GLM special case, which already tests the values.
|
|
708
|
+
if (
|
|
709
|
+
fit.params["upper"].fixed
|
|
710
|
+
and fit.params["lower"].fixed
|
|
711
|
+
and fit.params["upper"].value == 1.0
|
|
712
|
+
and fit.params["lower"].value == 0.0
|
|
713
|
+
):
|
|
714
|
+
return _profile_interval_fixed(fit, target=target, level=level, theta_hat=theta_hat)
|
|
715
|
+
return _profile_interval_estimated(fit, definition=definition, level=level, theta_hat=theta_hat)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# ------------------------------------------------------------------------------------------
|
|
719
|
+
# Delta method (cross-check).
|
|
720
|
+
# ------------------------------------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def delta_interval(
|
|
724
|
+
fit: Fit, *, definition: Definition, target: float, level: float, theta_hat: float, value: float
|
|
725
|
+
) -> IntervalResult:
|
|
726
|
+
"""The delta-method interval for a solved threshold (the cross-check).
|
|
727
|
+
|
|
728
|
+
Computed on the log scale and exponentiated -- even on a linear axis, where
|
|
729
|
+
``sigma_log = sigma_theta / value`` -- which is what keeps the interval positive and
|
|
730
|
+
equivariant under ``x -> c * x`` regardless of axis (this module's docstring).
|
|
731
|
+
|
|
732
|
+
Parameters
|
|
733
|
+
----------
|
|
734
|
+
fit
|
|
735
|
+
A ``status is Status.OK`` fit.
|
|
736
|
+
definition
|
|
737
|
+
The :class:`~robustmargin.Definition` the threshold solves for (needed for ``d(q)/d(u)``
|
|
738
|
+
and ``d(q)/d(l)`` when an asymptote is estimated).
|
|
739
|
+
target
|
|
740
|
+
The already-resolved target performance.
|
|
741
|
+
level
|
|
742
|
+
The confidence level, strictly between 0 and 1.
|
|
743
|
+
theta_hat, value
|
|
744
|
+
:func:`solve_value`'s two return values.
|
|
745
|
+
|
|
746
|
+
Returns
|
|
747
|
+
-------
|
|
748
|
+
IntervalResult
|
|
749
|
+
``censoring`` is always :data:`~robustmargin.Censoring.NONE`: the delta method has no
|
|
750
|
+
open-sided outcome.
|
|
751
|
+
"""
|
|
752
|
+
var_theta = _theta_variance(fit, definition=definition, target=target)
|
|
753
|
+
sigma_theta = math.sqrt(max(var_theta, 0.0))
|
|
754
|
+
|
|
755
|
+
sigma_log = sigma_theta if fit.axis.scale == "log" else sigma_theta / value
|
|
756
|
+
z_score = float(norm.ppf(0.5 + level / 2.0))
|
|
757
|
+
# `math.exp` raises OverflowError rather than returning inf, and a flat curve on a coarse
|
|
758
|
+
# grid makes `sigma_log` large enough to reach it. An interval whose upper end overflows is
|
|
759
|
+
# not a bounded interval, so it takes the same route as an unbounded profile: the estimate
|
|
760
|
+
# with no bounds, and the reason in `warnings`.
|
|
761
|
+
half_width = z_score * sigma_log
|
|
762
|
+
if not math.isfinite(half_width) or half_width > _MAX_LOG_HALF_WIDTH:
|
|
763
|
+
return IntervalResult(
|
|
764
|
+
lo=None,
|
|
765
|
+
hi=None,
|
|
766
|
+
censoring=Censoring.NONE,
|
|
767
|
+
warnings=(
|
|
768
|
+
"intervals.delta_interval: the delta standard error on the log scale "
|
|
769
|
+
f"({sigma_log!r}) is too large for a finite interval; the design does not "
|
|
770
|
+
"constrain the threshold, so no bounds are reported",
|
|
771
|
+
),
|
|
772
|
+
)
|
|
773
|
+
lo = value * math.exp(-half_width)
|
|
774
|
+
hi = value * math.exp(half_width)
|
|
775
|
+
if not (math.isfinite(lo) and math.isfinite(hi)):
|
|
776
|
+
return IntervalResult(
|
|
777
|
+
lo=None,
|
|
778
|
+
hi=None,
|
|
779
|
+
censoring=Censoring.NONE,
|
|
780
|
+
warnings=(
|
|
781
|
+
"intervals.delta_interval: the interval endpoints are not finite; the design "
|
|
782
|
+
"does not constrain the threshold, so no bounds are reported",
|
|
783
|
+
),
|
|
784
|
+
)
|
|
785
|
+
return IntervalResult(lo=lo, hi=hi, censoring=Censoring.NONE, warnings=())
|