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,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=())