robustmargin 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- robustmargin/__init__.py +90 -0
- robustmargin/censoring.py +652 -0
- robustmargin/empirical.py +348 -0
- robustmargin/intervals.py +785 -0
- robustmargin/models.py +2119 -0
- robustmargin/py.typed +0 -0
- robustmargin/ratio.py +941 -0
- robustmargin/report.py +650 -0
- robustmargin/schema/scorecard-v1.json +777 -0
- robustmargin/testing.py +465 -0
- robustmargin/threshold.py +631 -0
- robustmargin/types.py +541 -0
- robustmargin/validation.py +722 -0
- robustmargin-0.1.1.dist-info/METADATA +281 -0
- robustmargin-0.1.1.dist-info/RECORD +18 -0
- robustmargin-0.1.1.dist-info/WHEEL +5 -0
- robustmargin-0.1.1.dist-info/licenses/LICENSE +201 -0
- robustmargin-0.1.1.dist-info/top_level.txt +1 -0
robustmargin/ratio.py
ADDED
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
"""The ratio-of-two-thresholds result and :func:`ratio_interval`, which computes one.
|
|
2
|
+
|
|
3
|
+
:func:`ratio_interval` solves the classic Fieller quadratic --
|
|
4
|
+
``{rho : A*rho^2 - 2*B*rho + K <= 0}`` for ``A = theta_b^2 - z^2*Vb``,
|
|
5
|
+
``B = theta_a*theta_b - z^2*C``, ``K = theta_a^2 - z^2*Va`` (``C = 0`` under
|
|
6
|
+
``dependence="independent"``) -- on the *natural* severity scale, or the log-delta interval
|
|
7
|
+
(``log(ratio)``, variance ``Va + Vb`` on the log scale, exponentiated back around the estimate).
|
|
8
|
+
Both reuse the exact per-threshold delta-method variance :mod:`robustmargin.intervals` already
|
|
9
|
+
computes for a single threshold's own interval (``intervals._theta_variance``), rather than
|
|
10
|
+
reassembling the gradient a second time.
|
|
11
|
+
|
|
12
|
+
**A note on three different things all called ``theta``.** In the Fieller construction
|
|
13
|
+
``theta`` is a threshold's value on the *original* severity scale (:attr:`Threshold.value`); in the
|
|
14
|
+
log-delta construction ``theta`` is ``log(x*)``; and :mod:`robustmargin.intervals` writes ``theta``
|
|
15
|
+
for yet a third thing, the axis it actually optimises over (``log(severity)`` on a log axis, raw
|
|
16
|
+
severity on a linear one). All three coincide only on a log axis. Fieller here always converts
|
|
17
|
+
``intervals``' transformed-scale ``Var(theta)`` to the natural scale via the delta method's own
|
|
18
|
+
chain rule for ``value = exp(theta)`` on a log axis (``Var(value) = value^2 * Var(theta)``) or the
|
|
19
|
+
identity map on a linear axis (``value = theta``, so ``Var(value) = Var(theta)`` directly) -- see
|
|
20
|
+
``_fieller_variance``'s docstring for why this is computed directly rather than through a
|
|
21
|
+
``value^2 * sigma_log^2`` round trip (the two agree wherever ``value != 0``; the direct route has no
|
|
22
|
+
removable singularity at ``value == 0`` on a linear axis).
|
|
23
|
+
|
|
24
|
+
Several questions the ratio construction leaves open are settled as follows rather than guessed
|
|
25
|
+
at: a censored or failed input threshold propagates a status/censoring flag with no numbers,
|
|
26
|
+
direction-aware for which side is censored; ``level=None`` inherits from the two inputs, which
|
|
27
|
+
must agree, and an explicit mismatching ``level`` also raises; a Fieller lower root below zero is
|
|
28
|
+
intersected with the positive parameter space -- ``lo = 0.0``, ``shape`` stays ``BOUNDED``, the
|
|
29
|
+
unclipped root goes in ``warnings``; an ``EXCLUSIVE``-candidate set whose two roots are both
|
|
30
|
+
negative is exactly the whole positive axis, not a two-ray set to clip -- reported as
|
|
31
|
+
``UNBOUNDED``; a ``log_delta`` interval too wide to exponentiate raises rather than inventing a
|
|
32
|
+
shape ``Ratio`` has no member for; and with ``K > 0`` the ``EXCLUSIVE``-candidate branch
|
|
33
|
+
(``A < 0``, an open region of the parameter space) yields a half-line, reported as
|
|
34
|
+
``IntervalShape.HALF_OPEN``. The measure-zero boundary ``A == 0`` next to it yields the same
|
|
35
|
+
half-line and is reported the same way. The shared-``Fit`` check of the
|
|
36
|
+
``dependence="independent"`` guard is made by :func:`ratio_interval`, not by :class:`Ratio`.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import math
|
|
42
|
+
from collections.abc import Mapping
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
|
|
45
|
+
from scipy.stats import norm
|
|
46
|
+
|
|
47
|
+
from robustmargin.intervals import _MAX_LOG_HALF_WIDTH, _theta_variance
|
|
48
|
+
from robustmargin.threshold import Threshold, _resolve_target
|
|
49
|
+
from robustmargin.types import Censoring, IntervalShape, JSONValue, Status, _check_finite_number
|
|
50
|
+
|
|
51
|
+
__all__ = ["Ratio", "ratio_interval"]
|
|
52
|
+
|
|
53
|
+
_VALID_METHODS = frozenset({"fieller", "log_delta"})
|
|
54
|
+
_VALID_DEPENDENCE = frozenset({"independent"})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _check_finite(value: float | None, *, label: str) -> None:
|
|
58
|
+
if value is not None:
|
|
59
|
+
_check_finite_number(value, label=f"Ratio.{label}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, kw_only=True)
|
|
63
|
+
class Ratio:
|
|
64
|
+
"""The ratio of two thresholds' severities, on the same axis, with a confidence interval.
|
|
65
|
+
|
|
66
|
+
A Fieller-type interval is not always a single bounded interval: ``shape`` records
|
|
67
|
+
which geometry ``lo``/``hi`` describe, and that geometry is never clipped to look bounded
|
|
68
|
+
when it isn't.
|
|
69
|
+
|
|
70
|
+
**A failed or censored ratio carries no numbers.** Whenever ``status`` is not ``OK`` or
|
|
71
|
+
``censoring`` is not ``NONE``, ``shape``, ``estimate``, ``lo`` and ``hi`` are all ``None`` --
|
|
72
|
+
:func:`ratio_interval` propagates the input thresholds' status/censoring rather than
|
|
73
|
+
reporting a number, direction-aware for which side is censored. Only on
|
|
74
|
+
the ``OK`` + ``NONE`` path is ``shape`` required to be set, along with the numbers it
|
|
75
|
+
governs.
|
|
76
|
+
|
|
77
|
+
Attributes
|
|
78
|
+
----------
|
|
79
|
+
threshold_a, threshold_b
|
|
80
|
+
The two :class:`~robustmargin.Threshold` results the ratio is between (``a / b``). Their
|
|
81
|
+
axes must match on ``name`` and ``unit``, and their ``definition`` must be equal
|
|
82
|
+
(a ratio between two different target-performance definitions is not one number); a
|
|
83
|
+
mismatch on either raises. With ``dependence="independent"``, the two fits must not be
|
|
84
|
+
the very same object (``threshold_a.fit is threshold_b.fit``, object identity only) and
|
|
85
|
+
must not share a cluster id. Both checks are **necessary, not sufficient**: together they
|
|
86
|
+
can only see object identity and the ``cluster_ids`` a :class:`~robustmargin.Fit` records,
|
|
87
|
+
not whether the two series were drawn from overlapping observations that carry no cluster
|
|
88
|
+
id. Equal fits **by value** are not rejected: two disjoint samples can produce identical
|
|
89
|
+
counts, and that ratio is valid -- only the identical *object* is refused.
|
|
90
|
+
``ratio_interval`` checks only object identity and ``cluster_ids``, for exactly this reason:
|
|
91
|
+
a full shared-data check would need an observation id that nothing in the package records
|
|
92
|
+
yet (deferred to v0.2's paired dependence work).
|
|
93
|
+
estimate
|
|
94
|
+
The point estimate of the ratio. Required and equal to
|
|
95
|
+
``threshold_a.value / threshold_b.value`` (``math.isclose``, ``rel_tol=1e-9``) when
|
|
96
|
+
``status`` is ``OK`` and ``censoring`` is ``NONE``; ``None`` otherwise.
|
|
97
|
+
lo, hi
|
|
98
|
+
The interval endpoints, read according to ``shape``, only when ``status`` is ``OK`` and
|
|
99
|
+
``censoring`` is ``NONE``:
|
|
100
|
+
|
|
101
|
+
- ``BOUNDED``: ``[lo, hi]``, both set, ``lo <= hi``, and ``lo <= estimate <= hi``.
|
|
102
|
+
``method="log_delta"`` additionally requires ``lo > 0``. Also covers a Fieller set
|
|
103
|
+
bounded above but not below on its own, intersected to ``[0, hi]``; ``HALF_OPEN`` is
|
|
104
|
+
never used for that case.
|
|
105
|
+
- ``UNBOUNDED``: both ``None`` (the whole real line, or the positive parameter space
|
|
106
|
+
when intersected); it carries ``estimate``.
|
|
107
|
+
- ``EXCLUSIVE``: both set, ``lo < hi`` strictly, meaning
|
|
108
|
+
``(-inf, lo] union [hi, inf)``, and ``estimate`` lies in that union (``estimate <=
|
|
109
|
+
lo`` or ``estimate >= hi``) -- the point estimate always lies inside the Fieller
|
|
110
|
+
confidence set, never in the excluded gap. **Unreachable via ``ratio_interval`` in
|
|
111
|
+
v0.1**: the invariant stays enforced for a directly-constructed
|
|
112
|
+
``Ratio`` and for v0.2's ``dependence="paired"``.
|
|
113
|
+
- ``HALF_OPEN``: ``lo`` set, ``hi`` is ``None``, meaning ``[lo, inf)``, and
|
|
114
|
+
``lo <= estimate``. ``method="log_delta"`` additionally requires ``lo > 0``, as for
|
|
115
|
+
``BOUNDED``. This is what a two-ray Fieller set with ``K > 0`` is once intersected
|
|
116
|
+
with the positive parameter space: an open region of the parameter space, reached
|
|
117
|
+
whenever the denominator is poorly determined and the numerator is not, not a
|
|
118
|
+
measure-zero boundary.
|
|
119
|
+
|
|
120
|
+
Both ``None`` otherwise.
|
|
121
|
+
shape
|
|
122
|
+
The :class:`~robustmargin.IntervalShape` of ``(lo, hi)`` when ``status`` is ``OK`` and
|
|
123
|
+
``censoring`` is ``NONE``; ``None`` otherwise (including when ``status``/``censoring``
|
|
124
|
+
make a shape meaningless).
|
|
125
|
+
method
|
|
126
|
+
``"fieller"`` or ``"log_delta"``. ``"log_delta"`` additionally requires, on the
|
|
127
|
+
``OK`` + ``NONE`` path, that ``threshold_a.axis.scale`` and ``threshold_b.axis.scale``
|
|
128
|
+
are both ``"log"`` and that ``threshold_a.value`` and ``threshold_b.value`` are both
|
|
129
|
+
``> 0`` (the log-scale delta method is undefined off the log axis or through zero).
|
|
130
|
+
dependence
|
|
131
|
+
``"independent"`` in v0.1 (v0.2 adds ``"paired"``). With ``"independent"``, the
|
|
132
|
+
two thresholds' fits must not share a cluster id.
|
|
133
|
+
level
|
|
134
|
+
The confidence level, strictly between 0 and 1.
|
|
135
|
+
censoring
|
|
136
|
+
The :class:`~robustmargin.Censoring` propagated from the input thresholds,
|
|
137
|
+
direction-aware: read against the ratio itself, not against either threshold's own
|
|
138
|
+
tested range.
|
|
139
|
+
status
|
|
140
|
+
The :class:`~robustmargin.Status` of this ratio.
|
|
141
|
+
warnings
|
|
142
|
+
Free-text notes surfaced alongside the ratio. Empty by default. :func:`ratio_interval`
|
|
143
|
+
carries both ``threshold_a.warnings`` and ``threshold_b.warnings`` here too -- in
|
|
144
|
+
particular a ``"NON_MONOTONE_DATA: "`` entry on either input threshold propagates
|
|
145
|
+
through, on every path (censored/failed and the ``OK``+``NONE`` path alike).
|
|
146
|
+
schema_version
|
|
147
|
+
The serialised-result schema version this object belongs to. Defaults to ``"1"``, the
|
|
148
|
+
only version ``from_dict`` reads; any other version is rejected.
|
|
149
|
+
provenance
|
|
150
|
+
An opaque mapping the caller may attach to record where the inputs came from.
|
|
151
|
+
robustmargin stores it and never interprets it. Empty by default.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
threshold_a: Threshold
|
|
155
|
+
threshold_b: Threshold
|
|
156
|
+
estimate: float | None
|
|
157
|
+
lo: float | None
|
|
158
|
+
hi: float | None
|
|
159
|
+
shape: IntervalShape | None
|
|
160
|
+
method: str
|
|
161
|
+
dependence: str
|
|
162
|
+
level: float
|
|
163
|
+
censoring: Censoring
|
|
164
|
+
status: Status
|
|
165
|
+
warnings: tuple[str, ...] = ()
|
|
166
|
+
schema_version: str = "1"
|
|
167
|
+
provenance: Mapping[str, JSONValue] = field(default_factory=dict)
|
|
168
|
+
|
|
169
|
+
def __post_init__(self) -> None:
|
|
170
|
+
object.__setattr__(self, "warnings", tuple(self.warnings))
|
|
171
|
+
|
|
172
|
+
if self.threshold_a.axis.name != self.threshold_b.axis.name:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"Ratio.threshold_a and .threshold_b must share an axis name, got "
|
|
175
|
+
f"{self.threshold_a.axis.name!r} and {self.threshold_b.axis.name!r}"
|
|
176
|
+
)
|
|
177
|
+
if self.threshold_a.axis.unit != self.threshold_b.axis.unit:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
"Ratio.threshold_a and .threshold_b must share an axis unit, got "
|
|
180
|
+
f"{self.threshold_a.axis.unit!r} and {self.threshold_b.axis.unit!r}"
|
|
181
|
+
)
|
|
182
|
+
if self.threshold_a.definition != self.threshold_b.definition:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
"Ratio.threshold_a.definition must equal Ratio.threshold_b.definition, got "
|
|
185
|
+
f"{self.threshold_a.definition!r} and {self.threshold_b.definition!r}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if self.method not in _VALID_METHODS:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"Ratio.method must be one of {sorted(_VALID_METHODS)}, got {self.method!r}"
|
|
191
|
+
)
|
|
192
|
+
if self.dependence not in _VALID_DEPENDENCE:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"Ratio.dependence must be one of {sorted(_VALID_DEPENDENCE)}, "
|
|
195
|
+
f"got {self.dependence!r}"
|
|
196
|
+
)
|
|
197
|
+
_check_finite_number(self.level, label="Ratio.level")
|
|
198
|
+
if not (0.0 < self.level < 1.0):
|
|
199
|
+
raise ValueError(f"Ratio.level must satisfy 0 < level < 1, got {self.level!r}")
|
|
200
|
+
|
|
201
|
+
# The constructor does not check `threshold_a.fit is threshold_b.fit`. A directly
|
|
202
|
+
# constructed `Ratio` over one `Fit` under `dependence="independent"` is accepted,
|
|
203
|
+
# although its interval is wrong (that ratio is identically 1 with zero variance);
|
|
204
|
+
# `ratio_interval` refuses it, and every real caller goes through that function. The
|
|
205
|
+
# check stays out of the constructor so that the public type's accepted inputs do not
|
|
206
|
+
# change. The cluster-id check below is enforced here.
|
|
207
|
+
if self.dependence == "independent":
|
|
208
|
+
ids_a = self.threshold_a.fit.cluster_ids
|
|
209
|
+
ids_b = self.threshold_b.fit.cluster_ids
|
|
210
|
+
if ids_a is not None and ids_b is not None:
|
|
211
|
+
overlap = set(ids_a) & set(ids_b)
|
|
212
|
+
if overlap:
|
|
213
|
+
raise ValueError(
|
|
214
|
+
"Ratio: threshold_a.fit and threshold_b.fit share cluster id(s) "
|
|
215
|
+
f"{sorted(overlap, key=str)!r}, which is incompatible with "
|
|
216
|
+
"dependence='independent'"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
_check_finite(self.estimate, label="estimate")
|
|
220
|
+
_check_finite(self.lo, label="lo")
|
|
221
|
+
_check_finite(self.hi, label="hi")
|
|
222
|
+
|
|
223
|
+
is_ok_none = self.status is Status.OK and self.censoring is Censoring.NONE
|
|
224
|
+
|
|
225
|
+
if not is_ok_none:
|
|
226
|
+
if (
|
|
227
|
+
self.shape is not None
|
|
228
|
+
or self.estimate is not None
|
|
229
|
+
or self.lo is not None
|
|
230
|
+
or self.hi is not None
|
|
231
|
+
):
|
|
232
|
+
raise ValueError(
|
|
233
|
+
"Ratio.shape, .estimate, .lo and .hi must all be None when status is not "
|
|
234
|
+
"OK or censoring is not NONE"
|
|
235
|
+
)
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
if self.shape is None:
|
|
239
|
+
raise ValueError("Ratio.shape must be set when status is OK and censoring is NONE")
|
|
240
|
+
|
|
241
|
+
if self.threshold_a.censoring is not Censoring.NONE:
|
|
242
|
+
raise ValueError(
|
|
243
|
+
"Ratio.threshold_a.censoring must be NONE when the ratio's own status is OK "
|
|
244
|
+
f"and censoring is NONE, got {self.threshold_a.censoring!r}"
|
|
245
|
+
)
|
|
246
|
+
if self.threshold_b.censoring is not Censoring.NONE:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
"Ratio.threshold_b.censoring must be NONE when the ratio's own status is OK "
|
|
249
|
+
f"and censoring is NONE, got {self.threshold_b.censoring!r}"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if self.threshold_a.status is not Status.OK or self.threshold_a.value is None:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
"Ratio.threshold_a must have status OK with value set when the ratio's own "
|
|
255
|
+
"status is OK and censoring is NONE"
|
|
256
|
+
)
|
|
257
|
+
if self.threshold_b.status is not Status.OK or self.threshold_b.value is None:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
"Ratio.threshold_b must have status OK with value set when the ratio's own "
|
|
260
|
+
"status is OK and censoring is NONE"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if self.estimate is None:
|
|
264
|
+
raise ValueError("Ratio.estimate must be set when status is OK and censoring is NONE")
|
|
265
|
+
estimate = self.estimate
|
|
266
|
+
|
|
267
|
+
if not self.threshold_b.value > 0.0:
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"Ratio.threshold_b.value must be > 0 to divide by, got {self.threshold_b.value!r}"
|
|
270
|
+
)
|
|
271
|
+
expected = self.threshold_a.value / self.threshold_b.value
|
|
272
|
+
if not math.isclose(estimate, expected, rel_tol=1e-9):
|
|
273
|
+
raise ValueError(
|
|
274
|
+
"Ratio.estimate must equal threshold_a.value / threshold_b.value "
|
|
275
|
+
f"({expected!r}), got {estimate!r}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if self.shape is IntervalShape.BOUNDED:
|
|
279
|
+
if self.lo is None or self.hi is None:
|
|
280
|
+
raise ValueError(
|
|
281
|
+
"Ratio.lo and .hi must both be set when shape is BOUNDED and censoring is NONE"
|
|
282
|
+
)
|
|
283
|
+
if not self.lo <= self.hi:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"Ratio.lo must not exceed .hi when shape is BOUNDED, got lo={self.lo!r}, "
|
|
286
|
+
f"hi={self.hi!r}"
|
|
287
|
+
)
|
|
288
|
+
if not (self.lo <= estimate <= self.hi):
|
|
289
|
+
raise ValueError(
|
|
290
|
+
"Ratio.estimate must lie within [lo, hi] when shape is BOUNDED, got "
|
|
291
|
+
f"estimate={estimate!r}, lo={self.lo!r}, hi={self.hi!r}"
|
|
292
|
+
)
|
|
293
|
+
if self.method == "log_delta" and not self.lo > 0.0:
|
|
294
|
+
raise ValueError(
|
|
295
|
+
f"Ratio.lo must be > 0 when method='log_delta', got lo={self.lo!r}"
|
|
296
|
+
)
|
|
297
|
+
elif self.shape is IntervalShape.UNBOUNDED:
|
|
298
|
+
if self.lo is not None or self.hi is not None:
|
|
299
|
+
raise ValueError(
|
|
300
|
+
"Ratio.lo and .hi must both be None when shape is UNBOUNDED and censoring "
|
|
301
|
+
"is NONE"
|
|
302
|
+
)
|
|
303
|
+
elif self.shape is IntervalShape.EXCLUSIVE:
|
|
304
|
+
# Unreachable through `ratio_interval` while `dependence="independent"` fixes the
|
|
305
|
+
# covariance term at 0: every same-signed-root case that would
|
|
306
|
+
# produce this shape needs a nonzero covariance, which only v0.2's
|
|
307
|
+
# `dependence="paired"` can supply. The invariant stays enforced -- for a `Ratio`
|
|
308
|
+
# built directly (as `robustmargin.testing.fake_ratio` does) and for when `paired`
|
|
309
|
+
# lands -- rather than being deleted along with the now-dead production path.
|
|
310
|
+
if self.lo is None or self.hi is None:
|
|
311
|
+
raise ValueError(
|
|
312
|
+
"Ratio.lo and .hi must both be set when shape is EXCLUSIVE and censoring "
|
|
313
|
+
"is NONE"
|
|
314
|
+
)
|
|
315
|
+
if not self.lo < self.hi:
|
|
316
|
+
raise ValueError(
|
|
317
|
+
"Ratio.lo must be strictly less than .hi when shape is EXCLUSIVE, got "
|
|
318
|
+
f"lo={self.lo!r}, hi={self.hi!r}"
|
|
319
|
+
)
|
|
320
|
+
if not (estimate <= self.lo or estimate >= self.hi):
|
|
321
|
+
raise ValueError(
|
|
322
|
+
"Ratio.estimate must lie outside (lo, hi) when shape is EXCLUSIVE, got "
|
|
323
|
+
f"estimate={estimate!r}, lo={self.lo!r}, hi={self.hi!r}"
|
|
324
|
+
)
|
|
325
|
+
elif self.shape is IntervalShape.HALF_OPEN:
|
|
326
|
+
# `[lo, inf)`: always lower-bounded, never upper-bounded -- a set bounded only
|
|
327
|
+
# above is reported as `BOUNDED` `[0, hi]` instead.
|
|
328
|
+
if self.lo is None:
|
|
329
|
+
raise ValueError(
|
|
330
|
+
"Ratio.lo must be set when shape is HALF_OPEN and censoring is NONE"
|
|
331
|
+
)
|
|
332
|
+
if self.hi is not None:
|
|
333
|
+
raise ValueError(
|
|
334
|
+
"Ratio.hi must be None when shape is HALF_OPEN and censoring is NONE, got "
|
|
335
|
+
f"hi={self.hi!r}"
|
|
336
|
+
)
|
|
337
|
+
if not (self.lo <= estimate):
|
|
338
|
+
raise ValueError(
|
|
339
|
+
"Ratio.estimate must be >= lo when shape is HALF_OPEN, got "
|
|
340
|
+
f"estimate={estimate!r}, lo={self.lo!r}"
|
|
341
|
+
)
|
|
342
|
+
if self.method == "log_delta" and not self.lo > 0.0:
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"Ratio.lo must be > 0 when method='log_delta', got lo={self.lo!r}"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if self.method == "log_delta":
|
|
348
|
+
if self.threshold_a.axis.scale != "log" or self.threshold_b.axis.scale != "log":
|
|
349
|
+
raise ValueError(
|
|
350
|
+
"Ratio.method='log_delta' requires threshold_a.axis.scale and "
|
|
351
|
+
"threshold_b.axis.scale to both be 'log', got "
|
|
352
|
+
f"{self.threshold_a.axis.scale!r} and {self.threshold_b.axis.scale!r}"
|
|
353
|
+
)
|
|
354
|
+
if not (self.threshold_a.value > 0.0 and self.threshold_b.value > 0.0):
|
|
355
|
+
raise ValueError(
|
|
356
|
+
"Ratio.method='log_delta' requires threshold_a.value and "
|
|
357
|
+
f"threshold_b.value to both be > 0, got {self.threshold_a.value!r} and "
|
|
358
|
+
f"{self.threshold_b.value!r}"
|
|
359
|
+
)
|
|
360
|
+
if self.shape not in (IntervalShape.BOUNDED, IntervalShape.HALF_OPEN):
|
|
361
|
+
raise ValueError(
|
|
362
|
+
"Ratio.method='log_delta' requires shape=BOUNDED or HALF_OPEN, got "
|
|
363
|
+
f"shape={self.shape!r}"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ------------------------------------------------------------------------------------------
|
|
368
|
+
# ratio_interval(): guards, per-threshold variance, Fieller/log-delta shape, censoring
|
|
369
|
+
# propagation.
|
|
370
|
+
# ------------------------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
# `Censoring` flip for the denominator: a directional flag on `threshold_b`
|
|
373
|
+
# is re-read against the ratio in the opposite direction from the same flag on `threshold_a`.
|
|
374
|
+
_CENSORING_FLIP: dict[Censoring, Censoring] = {
|
|
375
|
+
Censoring.RIGHT: Censoring.LEFT,
|
|
376
|
+
Censoring.LEFT: Censoring.RIGHT,
|
|
377
|
+
Censoring.OPEN_UPPER: Censoring.OPEN_LOWER,
|
|
378
|
+
Censoring.OPEN_LOWER: Censoring.OPEN_UPPER,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _theta_variance_for(t: Threshold) -> float:
|
|
383
|
+
"""``Var(theta)`` on :mod:`robustmargin.intervals`' own transformed axis, for threshold ``t`` --
|
|
384
|
+
delegates the delta-method gradient assembly to ``intervals._theta_variance`` (the same
|
|
385
|
+
function :func:`robustmargin.intervals.delta_interval` uses for its own cross-check interval)
|
|
386
|
+
rather than reconstructing it here, and never reconstructs a variance by inverting ``t.lo``/
|
|
387
|
+
``t.hi`` -- that fails for a profile interval (whose ``lo``/``hi`` are not delta-method
|
|
388
|
+
endpoints at all) and for the open- or both-sides-open cases, where one or both are ``None``.
|
|
389
|
+
Clamped to ``>= 0`` against floating-point noise, mirroring
|
|
390
|
+
``intervals.delta_interval``'s own ``max(var_theta, 0.0)``.
|
|
391
|
+
"""
|
|
392
|
+
target, _ = _resolve_target(t.definition, fit=t.fit)
|
|
393
|
+
return max(_theta_variance(t.fit, definition=t.definition, target=target), 0.0)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _fieller_variance(t: Threshold, *, value: float) -> float:
|
|
397
|
+
"""``Va``/``Vb`` in the Fieller quadratic: the *natural*-severity-scale
|
|
398
|
+
variance of ``t``'s point estimate (module docstring's "three different thetas" note).
|
|
399
|
+
|
|
400
|
+
Converts ``_theta_variance_for``'s transformed-scale ``Var(theta)`` via the delta method's
|
|
401
|
+
own chain rule for ``value = exp(theta)`` on a log axis (``Var(value) = value^2 *
|
|
402
|
+
Var(theta)``), or the identity on a linear axis (``value = theta`` already, so
|
|
403
|
+
``Var(value) = Var(theta)`` directly). This is algebraically the same
|
|
404
|
+
``Va = value^2 * sigma_log^2`` identity (``sigma_log`` being ``intervals.delta_interval``'s own
|
|
405
|
+
log-scale standard error) wherever ``value != 0``, but computed without ever dividing by
|
|
406
|
+
``value``, so a linear-axis threshold whose value is exactly 0 gets the exact answer instead of
|
|
407
|
+
a 0/0 division.
|
|
408
|
+
"""
|
|
409
|
+
var_theta = _theta_variance_for(t)
|
|
410
|
+
return value * value * var_theta if t.axis.scale == "log" else var_theta
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# How far below zero `disc` may fall, relative to the scale of the two
|
|
414
|
+
# terms it is the difference of, before a clamp to 0.0 is worth a warning rather than treated as
|
|
415
|
+
# ordinary floating-point rounding. Matches the `rel_tol=1e-9` convention `Covariance` already
|
|
416
|
+
# uses for its own symmetry check (`models.py`).
|
|
417
|
+
_DISC_RELATIVE_EPS = 1e-9
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _stable_quadratic_roots(
|
|
421
|
+
*, a_coef: float, b_coef: float, k_coef: float, disc: float
|
|
422
|
+
) -> tuple[float, float]:
|
|
423
|
+
"""The two roots of ``A*rho^2 - 2*B*rho + K = 0`` (note the factor of 2 on ``B``, matching
|
|
424
|
+
this module's Fieller coefficients), computed with the substitution that avoids catastrophic
|
|
425
|
+
cancellation in the naive ``(B +/- sqrt(disc)) / A`` when ``|B|`` and ``sqrt(disc)`` are
|
|
426
|
+
close -- exactly the case at the branch boundary this module cares about (measured relative
|
|
427
|
+
error ``1.35e-6`` at ``A=1e-10``, ``5.6e-5`` at ``A=1e-12`` from the naive formula):
|
|
428
|
+
``q = B + copysign(sqrt(disc), B)``, ``r1 = q/A``, ``r2 = K/q``. Requires
|
|
429
|
+
``disc >= 0`` and ``a_coef != 0`` -- both already guaranteed by every call site.
|
|
430
|
+
"""
|
|
431
|
+
sq = math.sqrt(disc)
|
|
432
|
+
if b_coef == 0.0 and sq == 0.0:
|
|
433
|
+
# A fully degenerate double root at 0 (only reachable when B and K are both exactly 0
|
|
434
|
+
# too, given disc = B^2 - A*K and a_coef != 0) -- the general formula below would divide
|
|
435
|
+
# 0.0 by 0.0 for the second root.
|
|
436
|
+
return 0.0, 0.0
|
|
437
|
+
q = b_coef + math.copysign(sq, b_coef)
|
|
438
|
+
return q / a_coef, k_coef / q
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _fieller_shape(
|
|
442
|
+
*, theta_a: float, va: float, theta_b: float, vb: float, z: float
|
|
443
|
+
) -> tuple[IntervalShape, float | None, float | None, tuple[str, ...]]:
|
|
444
|
+
"""Solve ``{rho : A*rho^2 - 2*B*rho + K <= 0}`` (module docstring) for
|
|
445
|
+
``A = theta_b^2 - z^2*Vb``, ``B = theta_a*theta_b`` (``C = 0`` under
|
|
446
|
+
``dependence="independent"``), ``K = theta_a^2 - z^2*Va``, then intersects the result with
|
|
447
|
+
the positive parameter space a severity ratio must live in.
|
|
448
|
+
|
|
449
|
+
``A > 0``: ``BOUNDED``. The discriminant is non-negative here: the quadratic at the point
|
|
450
|
+
estimate is ``q(theta_a/theta_b) = -z^2 * (Va + rho_hat^2 * Vb) <= 0`` and ``A > 0``, so two
|
|
451
|
+
real roots bracket the estimate. Under ``C = 0`` this holds unconditionally; the guard in
|
|
452
|
+
the code is kept for a future estimated covariance. A lower root below zero is intersected
|
|
453
|
+
with the positive parameter space -- ``lo = 0.0``, the unclipped root goes in ``warnings``,
|
|
454
|
+
``shape`` stays ``BOUNDED`` (the upper-only geometry never becomes ``HALF_OPEN``).
|
|
455
|
+
|
|
456
|
+
``A < 0`` with a positive discriminant: ``K > 0`` implies ``theta_a > 0`` and hence
|
|
457
|
+
``B = theta_a*theta_b > 0`` under independence, so the roots' product (``K/A``) and sum
|
|
458
|
+
(``2*B/A``) pin down exactly two outcomes:
|
|
459
|
+
|
|
460
|
+
- ``K > 0``: the roots are opposite-signed (product ``< 0``), so intersected with the
|
|
461
|
+
positive axis the set is exactly ``[hi, inf)`` -- ``HALF_OPEN`` with ``lo`` set to the
|
|
462
|
+
positive root (the larger of the two), **not** ``EXCLUSIVE``.
|
|
463
|
+
- ``K <= 0``: both roots are negative (product ``>= 0``, sum ``< 0``), so the set is exactly
|
|
464
|
+
``(0, inf)`` -- ``UNBOUNDED``, exact.
|
|
465
|
+
|
|
466
|
+
``EXCLUSIVE`` (same-signed roots with ``A < 0``) needs ``B <= 0``, i.e. a nonzero covariance
|
|
467
|
+
term -- unreachable through this function while ``dependence="independent"`` fixes ``C = 0``.
|
|
468
|
+
:class:`Ratio`'s invariants for it stay enforced against v0.2's ``dependence="paired"``.
|
|
469
|
+
|
|
470
|
+
``A < 0`` with a non-positive discriminant: ``UNBOUNDED`` (no real roots, confidence set is
|
|
471
|
+
the whole line).
|
|
472
|
+
|
|
473
|
+
``A == 0`` exactly (``g == 1``): the quadratic degenerates to the linear
|
|
474
|
+
``-2*B*rho + K <= 0``, i.e. ``rho >= K/(2*B)`` when ``B > 0``. ``K > 0`` puts that boundary
|
|
475
|
+
strictly above zero -- ``HALF_OPEN`` with ``lo = K/(2*B)``. ``A == 0`` is itself
|
|
476
|
+
measure-zero, but it borders the open ``A < 0, K > 0`` region, which gives the same
|
|
477
|
+
half-line, so the two are reported alike. ``K <= 0`` puts the boundary at or below zero, so
|
|
478
|
+
the intersected set is exactly ``(0, inf)`` -- ``UNBOUNDED``.
|
|
479
|
+
|
|
480
|
+
Raises
|
|
481
|
+
------
|
|
482
|
+
ValueError
|
|
483
|
+
If ``va`` or ``vb`` is not finite: a NaN variance passes none
|
|
484
|
+
of the sign tests above and would otherwise fall through to the ``A == 0`` branch,
|
|
485
|
+
reporting "``A`` is exactly 0" -- a false statement about a quantity that was never
|
|
486
|
+
computed to a real number in the first place.
|
|
487
|
+
"""
|
|
488
|
+
if not (math.isfinite(va) and math.isfinite(vb)):
|
|
489
|
+
raise ValueError(
|
|
490
|
+
"ratio_interval: method='fieller' needs finite per-threshold variances, got "
|
|
491
|
+
f"Va={va!r} and Vb={vb!r}. A non-finite delta-method variance means the interval "
|
|
492
|
+
"shape cannot be determined -- this is a distinct failure from the A == 0 boundary "
|
|
493
|
+
"and is not silently routed through it"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
a_coef = theta_b * theta_b - z * z * vb
|
|
497
|
+
b_coef = theta_a * theta_b
|
|
498
|
+
k_coef = theta_a * theta_a - z * z * va
|
|
499
|
+
disc = b_coef * b_coef - a_coef * k_coef
|
|
500
|
+
|
|
501
|
+
if a_coef > 0.0:
|
|
502
|
+
collected_warnings: list[str] = []
|
|
503
|
+
scale = max(abs(b_coef * b_coef), abs(a_coef * k_coef), 1.0)
|
|
504
|
+
if disc < -_DISC_RELATIVE_EPS * scale:
|
|
505
|
+
collected_warnings.append(
|
|
506
|
+
f"ratio_interval: the Fieller discriminant is negative ({disc!r}) in the "
|
|
507
|
+
"A > 0 branch, where it is mathematically guaranteed to be >= 0 (the point "
|
|
508
|
+
"estimate lies inside the confidence set, so the roots are real); clamped to "
|
|
509
|
+
"0.0 rather than "
|
|
510
|
+
"silently -- this may indicate numerical trouble upstream, not just "
|
|
511
|
+
"floating-point rounding"
|
|
512
|
+
)
|
|
513
|
+
disc_clamped = max(disc, 0.0)
|
|
514
|
+
r1, r2 = _stable_quadratic_roots(
|
|
515
|
+
a_coef=a_coef, b_coef=b_coef, k_coef=k_coef, disc=disc_clamped
|
|
516
|
+
)
|
|
517
|
+
lo, hi = sorted([r1, r2])
|
|
518
|
+
if lo < 0.0:
|
|
519
|
+
# K <= 0 puts rho=0 in the confidence set, so the lower root is below zero --
|
|
520
|
+
# impossible for a severity ratio. Intersect with the positive parameter space
|
|
521
|
+
# rather than report a negative bound. The upper-only geometry this produces stays
|
|
522
|
+
# BOUNDED, never HALF_OPEN: HALF_OPEN is reserved for the lower-bounded-only shape
|
|
523
|
+
# below.
|
|
524
|
+
collected_warnings.append(
|
|
525
|
+
"ratio_interval: the Fieller lower root is below zero "
|
|
526
|
+
f"(unclipped lo={lo!r}); a severity ratio cannot be negative, so the reported "
|
|
527
|
+
"interval is intersected with the positive parameter space (lo=0.0) -- this "
|
|
528
|
+
"stays a conservative superset of the true confidence set"
|
|
529
|
+
)
|
|
530
|
+
lo = 0.0
|
|
531
|
+
return IntervalShape.BOUNDED, lo, hi, tuple(collected_warnings)
|
|
532
|
+
|
|
533
|
+
if a_coef < 0.0 and disc > 0.0:
|
|
534
|
+
r1, r2 = _stable_quadratic_roots(a_coef=a_coef, b_coef=b_coef, k_coef=k_coef, disc=disc)
|
|
535
|
+
lo, hi = sorted([r1, r2])
|
|
536
|
+
if k_coef <= 0.0:
|
|
537
|
+
# Under `dependence="independent"` (C=0), B = theta_a*theta_b > 0, so the roots'
|
|
538
|
+
# product K/A >= 0 (K <= 0, A < 0) and their sum 2*B/A < 0 -- both roots share a
|
|
539
|
+
# sign, and that sign is negative. The right ray [hi, inf) with hi < 0 already
|
|
540
|
+
# contains all of (0, inf), so intersected with the positive parameter space the
|
|
541
|
+
# confidence set is exactly (0, inf): UNBOUNDED, exact.
|
|
542
|
+
warning = (
|
|
543
|
+
"ratio_interval: the Fieller set is two rays both lying below zero "
|
|
544
|
+
f"(unclipped lo={lo!r}, hi={hi!r}); a severity ratio cannot be negative, so the "
|
|
545
|
+
"set intersected with the positive parameter space is unconstrained on (0, inf) "
|
|
546
|
+
"-- reporting shape=UNBOUNDED exactly, not as a conservative superset"
|
|
547
|
+
)
|
|
548
|
+
return IntervalShape.UNBOUNDED, None, None, (warning,)
|
|
549
|
+
# K > 0 with A < 0 forces opposite-signed roots (product K/A < 0), so
|
|
550
|
+
# under independence this branch is always a half-line intersected with the positive
|
|
551
|
+
# axis -- [hi, inf), reported as HALF_OPEN with lo=hi (the positive root), never
|
|
552
|
+
# EXCLUSIVE. EXCLUSIVE needs B <= 0 (a nonzero covariance term), unreachable while C=0.
|
|
553
|
+
return IntervalShape.HALF_OPEN, hi, None, ()
|
|
554
|
+
|
|
555
|
+
if a_coef < 0.0:
|
|
556
|
+
return IntervalShape.UNBOUNDED, None, None, ()
|
|
557
|
+
|
|
558
|
+
# `a_coef == 0.0` exactly: `g == 1`. The quadratic degenerates to the linear
|
|
559
|
+
# `-2*B*rho + K <= 0`. When `B > 0` (independence's usual case), the solution is
|
|
560
|
+
# `rho >= K/(2*B)`: a genuine half-line, split on K's sign.
|
|
561
|
+
if b_coef > 0.0:
|
|
562
|
+
boundary = k_coef / (2.0 * b_coef)
|
|
563
|
+
if k_coef > 0.0:
|
|
564
|
+
# A genuine finite half-line entirely on the positive side -- HALF_OPEN with
|
|
565
|
+
# lo = K/2B.
|
|
566
|
+
return IntervalShape.HALF_OPEN, boundary, None, ()
|
|
567
|
+
warning = (
|
|
568
|
+
"ratio_interval: the Fieller quadratic's leading coefficient A is exactly 0 (g == "
|
|
569
|
+
f"1); the degenerate half-line's boundary (rho >= {boundary!r}) is at or below "
|
|
570
|
+
"zero, so the confidence set intersected with the positive parameter space is "
|
|
571
|
+
"exactly (0, inf) -- reporting shape=UNBOUNDED, exact rather than a lossy superset "
|
|
572
|
+
"for this sub-case"
|
|
573
|
+
)
|
|
574
|
+
return IntervalShape.UNBOUNDED, None, None, (warning,)
|
|
575
|
+
|
|
576
|
+
# B <= 0 cannot arise under dependence="independent" (B = theta_a*theta_b, and both values
|
|
577
|
+
# are non-negative by Threshold's own invariant), so this is a defensive branch matching the
|
|
578
|
+
# general algebra rather than a reachable v0.1 case.
|
|
579
|
+
if b_coef == 0.0:
|
|
580
|
+
# A == 0 and B == 0 degenerate the inequality to `K <= 0`, so K > 0 is an EMPTY
|
|
581
|
+
# confidence set -- not a half-line, and emphatically not the whole axis. Reporting
|
|
582
|
+
# UNBOUNDED there would be the least informative possible answer to an input that is
|
|
583
|
+
# actually contradictory, so it raises rather than silently falling back. Unreachable
|
|
584
|
+
# while dependence="independent" keeps B = theta_a*theta_b >= 0 (B == 0 forces
|
|
585
|
+
# theta_a == 0, hence K = -z^2*Va <= 0), but v0.2's paired covariance can reach it.
|
|
586
|
+
if k_coef > 0.0:
|
|
587
|
+
raise ValueError(
|
|
588
|
+
"ratio_interval: the Fieller confidence set is empty (A == 0, B == 0, "
|
|
589
|
+
f"K == {k_coef!r} > 0), which no IntervalShape can represent and which means "
|
|
590
|
+
"the inputs are mutually contradictory rather than merely uninformative"
|
|
591
|
+
)
|
|
592
|
+
note = "no finite half-line endpoint either (B == 0 too)"
|
|
593
|
+
elif b_coef < 0.0:
|
|
594
|
+
note = f"rho <= {k_coef / (2.0 * b_coef)!r}"
|
|
595
|
+
else:
|
|
596
|
+
note = f"rho >= {k_coef / (2.0 * b_coef)!r}"
|
|
597
|
+
warning = (
|
|
598
|
+
"ratio_interval: the Fieller quadratic's leading coefficient A is exactly 0 (g == 1); "
|
|
599
|
+
f"the true confidence set is a half-line ({note}), which IntervalShape has no member "
|
|
600
|
+
"for in this direction -- reporting shape=UNBOUNDED, which is exact here rather than "
|
|
601
|
+
"clipping it to look bounded"
|
|
602
|
+
)
|
|
603
|
+
return IntervalShape.UNBOUNDED, None, None, (warning,)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _log_delta_shape(
|
|
607
|
+
*, theta_a: float, var_log_a: float, theta_b: float, var_log_b: float, z: float
|
|
608
|
+
) -> tuple[float, float]:
|
|
609
|
+
"""``log(ratio) = log(theta_a) - log(theta_b)``, variance ``Va + Vb`` (``C = 0`` under
|
|
610
|
+
``dependence="independent"``), exponentiated back around the point estimate. Always
|
|
611
|
+
``BOUNDED`` -- :class:`Ratio`'s own invariant requires it for ``method="log_delta"`` -- so a
|
|
612
|
+
half-width too large for a finite ``float`` has no shape left to fall back to and raises
|
|
613
|
+
instead: no number is invented, and no shape ``Ratio`` cannot represent for this method is
|
|
614
|
+
reported.
|
|
615
|
+
"""
|
|
616
|
+
sigma_log_ratio = math.sqrt(max(var_log_a + var_log_b, 0.0))
|
|
617
|
+
half_width = z * sigma_log_ratio
|
|
618
|
+
estimate = theta_a / theta_b
|
|
619
|
+
if not math.isfinite(half_width) or half_width > _MAX_LOG_HALF_WIDTH:
|
|
620
|
+
raise ValueError(
|
|
621
|
+
"ratio_interval: method='log_delta' has a log-scale standard error too large for a "
|
|
622
|
+
f"finite interval (sigma_log_ratio={sigma_log_ratio!r}); method='log_delta' must "
|
|
623
|
+
"report shape=BOUNDED (Ratio's own invariant) and there is no defensible bounded "
|
|
624
|
+
"interval for this input -- method='fieller' can report shape=UNBOUNDED explicitly "
|
|
625
|
+
"instead"
|
|
626
|
+
)
|
|
627
|
+
lo = estimate * math.exp(-half_width)
|
|
628
|
+
hi = estimate * math.exp(half_width)
|
|
629
|
+
# `lo > 0.0` as well as finiteness: a half-width under _MAX_LOG_HALF_WIDTH can still
|
|
630
|
+
# underflow `lo` to exactly 0.0 for a small enough estimate, and 0.0 is "finite". Without
|
|
631
|
+
# this the failure surfaces from Ratio.__post_init__ as the log_delta lo > 0 invariant
|
|
632
|
+
# rather than as an explanation of what actually went wrong.
|
|
633
|
+
if not (math.isfinite(lo) and math.isfinite(hi) and lo > 0.0):
|
|
634
|
+
raise ValueError(
|
|
635
|
+
"ratio_interval: method='log_delta' produced a non-finite interval endpoint for "
|
|
636
|
+
"this input; there is no defensible bounded interval to report"
|
|
637
|
+
)
|
|
638
|
+
return lo, hi
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _propagate_censoring(
|
|
642
|
+
t_a: Threshold, t_b: Threshold
|
|
643
|
+
) -> tuple[Status, Censoring, tuple[str, ...]]:
|
|
644
|
+
"""The ``(Status, Censoring)`` a :class:`Ratio` carries when at least one input threshold is
|
|
645
|
+
not ``(OK, NONE)``: a flag, never a number.
|
|
646
|
+
|
|
647
|
+
**Status.** ``Status.OK`` only when both inputs are ``OK`` (a ``(OK, RIGHT)`` threshold --
|
|
648
|
+
a converged fit whose bound is censored -- is an ``OK`` input). When exactly one input has a
|
|
649
|
+
failure status, the ratio carries it. When both do, and they **differ**, the ratio carries
|
|
650
|
+
``threshold_a``'s and records both in ``warnings``; no severity ordering over ``Status`` is
|
|
651
|
+
invented to pick a "worse" one -- none exists anywhere in this package.
|
|
652
|
+
|
|
653
|
+
**Censoring.** Direction-aware: a numerator's directional flag
|
|
654
|
+
(``RIGHT``/``LEFT``/``OPEN_UPPER``/``OPEN_LOWER``) carries straight across; a denominator's
|
|
655
|
+
flips (a larger denominator pushes the ratio *down*, and vice versa). When both carry a
|
|
656
|
+
directional flag, there is no coherent direction; ``threshold_a``'s is reported, with a
|
|
657
|
+
warning naming the ambiguity. This is the same ``threshold_a``-wins tie-break as ``Status``
|
|
658
|
+
above, applied to the other field, so the two never disagree about which input won.
|
|
659
|
+
"""
|
|
660
|
+
collected_warnings: list[str] = []
|
|
661
|
+
|
|
662
|
+
a_failed = t_a.status is not Status.OK
|
|
663
|
+
b_failed = t_b.status is not Status.OK
|
|
664
|
+
if a_failed and b_failed:
|
|
665
|
+
status = t_a.status
|
|
666
|
+
if t_a.status != t_b.status:
|
|
667
|
+
collected_warnings.append(
|
|
668
|
+
"ratio_interval: threshold_a and threshold_b have different failure statuses "
|
|
669
|
+
f"(threshold_a={t_a.status!s}, threshold_b={t_b.status!s}); threshold_a's "
|
|
670
|
+
"status is reported"
|
|
671
|
+
)
|
|
672
|
+
elif a_failed:
|
|
673
|
+
status = t_a.status
|
|
674
|
+
elif b_failed:
|
|
675
|
+
status = t_b.status
|
|
676
|
+
else:
|
|
677
|
+
status = Status.OK
|
|
678
|
+
|
|
679
|
+
a_dir = t_a.censoring
|
|
680
|
+
b_dir = t_b.censoring
|
|
681
|
+
a_has_direction = a_dir is not Censoring.NONE
|
|
682
|
+
b_has_direction = b_dir is not Censoring.NONE
|
|
683
|
+
if a_has_direction and b_has_direction:
|
|
684
|
+
censoring = a_dir
|
|
685
|
+
collected_warnings.append(
|
|
686
|
+
"ratio_interval: both threshold_a and threshold_b carry censoring "
|
|
687
|
+
f"(threshold_a={a_dir!s}, threshold_b={b_dir!s}); there is no coherent direction "
|
|
688
|
+
f"for the ratio's bound, so threshold_a's flag ({a_dir!s}) is reported"
|
|
689
|
+
)
|
|
690
|
+
elif a_has_direction:
|
|
691
|
+
censoring = a_dir
|
|
692
|
+
elif b_has_direction:
|
|
693
|
+
censoring = _CENSORING_FLIP[b_dir]
|
|
694
|
+
else:
|
|
695
|
+
censoring = Censoring.NONE
|
|
696
|
+
|
|
697
|
+
return status, censoring, tuple(collected_warnings)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def ratio_interval(
|
|
701
|
+
t_a: Threshold,
|
|
702
|
+
t_b: Threshold,
|
|
703
|
+
/,
|
|
704
|
+
*,
|
|
705
|
+
method: str,
|
|
706
|
+
dependence: str,
|
|
707
|
+
level: float | None = None,
|
|
708
|
+
) -> Ratio:
|
|
709
|
+
"""The ratio of two thresholds' severities, ``t_a / t_b``, on the same axis, with a
|
|
710
|
+
confidence interval.
|
|
711
|
+
|
|
712
|
+
``method="fieller"`` solves the classic Fieller quadratic on the natural severity scale;
|
|
713
|
+
``method="log_delta"`` uses the log-scale delta method and additionally requires, once both
|
|
714
|
+
inputs are known to be ``(OK, NONE)``, that both axes' ``scale`` are ``"log"`` and both
|
|
715
|
+
thresholds' ``value`` are ``> 0``. Both use the normal quantile ``z``, never a Student-t
|
|
716
|
+
quantile, matching the rest of robustmargin -- R ``drc``'s own
|
|
717
|
+
``EDcomp(interval="fieller")`` uses ``qt`` instead, which is why its interval endpoints (not
|
|
718
|
+
its point estimates) do not match robustmargin's at the usual reference tolerance
|
|
719
|
+
(the reference comparison at
|
|
720
|
+
https://github.com/robustmargin/robustmargin/blob/main/tests/reference/test_ratio_drc_edcomp.py).
|
|
721
|
+
|
|
722
|
+
``IntervalShape.EXCLUSIVE`` is **unreachable** through this function while
|
|
723
|
+
``dependence="independent"``: a two-ray Fieller set turns out, once intersected with the
|
|
724
|
+
positive parameter space, to always be either the half-line ``[hi, inf)`` (reported as
|
|
725
|
+
``IntervalShape.HALF_OPEN``; an open region of the parameter space, not a measure-zero
|
|
726
|
+
boundary -- how often it occurs is a property of the design's power) or the whole positive
|
|
727
|
+
axis (``UNBOUNDED``) -- never a genuine two-ray exclusion, which needs a nonzero covariance
|
|
728
|
+
term that only v0.2's ``dependence="paired"`` can supply. ``EXCLUSIVE``'s :class:`Ratio`
|
|
729
|
+
invariants stay enforced against that future, but no call through this function reaches them
|
|
730
|
+
today.
|
|
731
|
+
|
|
732
|
+
Parameters
|
|
733
|
+
----------
|
|
734
|
+
t_a, t_b
|
|
735
|
+
The two thresholds, ``a / b``. Must share ``axis.name``, ``axis.unit`` and
|
|
736
|
+
``definition``; under ``dependence="independent"``, their fits must not share a cluster
|
|
737
|
+
id. All three are :class:`Ratio`'s own ``__post_init__`` invariants and are not
|
|
738
|
+
duplicated here -- they fire when the resulting :class:`Ratio` is constructed, on every
|
|
739
|
+
path through this function.
|
|
740
|
+
method
|
|
741
|
+
``"fieller"`` or ``"log_delta"``.
|
|
742
|
+
dependence
|
|
743
|
+
``"independent"`` only, in this release. ``"paired"`` raises, naming that it is v0.2
|
|
744
|
+
work (mirrors :func:`robustmargin.threshold`'s own guard for the same request).
|
|
745
|
+
Under ``"independent"``, ``t_a.fit`` and ``t_b.fit`` must not be the same object and
|
|
746
|
+
must not share a cluster id -- both checks are necessary but **not sufficient** for
|
|
747
|
+
actual independence.
|
|
748
|
+
|
|
749
|
+
**This function does not verify independence; establishing it is the caller's
|
|
750
|
+
responsibility.** Nothing in robustmargin records an observation id (only
|
|
751
|
+
:attr:`~robustmargin.Fit.cluster_ids`, one entry per distinct cluster, not per row), so two
|
|
752
|
+
fits built from overlapping rows that carry no cluster ids are accepted here and produce
|
|
753
|
+
a ``Ratio`` whose stated coverage is wrong -- silently, because there is nothing left in
|
|
754
|
+
the package to check it against. This is a documented limitation, not a guarantee this
|
|
755
|
+
function makes.
|
|
756
|
+
level
|
|
757
|
+
``None`` (the default) inherits the confidence level from ``t_a``/``t_b``, which must
|
|
758
|
+
agree; an explicit ``level`` that disagrees with either also raises -- there is no
|
|
759
|
+
silently-assumed default.
|
|
760
|
+
|
|
761
|
+
Returns
|
|
762
|
+
-------
|
|
763
|
+
Ratio
|
|
764
|
+
If either input is censored or has a failed :class:`~robustmargin.Status`, the returned
|
|
765
|
+
``Ratio`` carries a propagated status/censoring flag and no numbers at all -- never a
|
|
766
|
+
one-sided bound built by mixing an exact censoring bound in one threshold with a
|
|
767
|
+
model-fitted value and its sampling error in the other. ``warnings`` always carries both
|
|
768
|
+
``t_a.warnings`` and ``t_b.warnings``, whichever path is taken.
|
|
769
|
+
|
|
770
|
+
Raises
|
|
771
|
+
------
|
|
772
|
+
ValueError
|
|
773
|
+
On any of: ``t_a.fit is t_b.fit`` under ``dependence="independent"`` (checked directly
|
|
774
|
+
by this function); mismatched axis name/unit, mismatched ``definition``, or shared cluster
|
|
775
|
+
ids under ``dependence="independent"`` (via :class:`Ratio`'s own validation, once this
|
|
776
|
+
function reaches the point of constructing one); ``method`` outside
|
|
777
|
+
``{"fieller", "log_delta"}``; ``dependence`` anything other than ``"independent"``
|
|
778
|
+
(``"paired"`` gets its own message, naming it v0.2 work); ``level`` outside ``(0, 1)``, or a
|
|
779
|
+
``level`` mismatch; ``threshold_b.value == 0`` (nothing to divide by); for
|
|
780
|
+
``method="log_delta"`` once both inputs are ``(OK, NONE)``, either axis not ``scale="log"``
|
|
781
|
+
or either ``value`` not ``> 0``; for ``method="log_delta"``, a combined log-scale standard
|
|
782
|
+
error too large to exponentiate finitely; or, for ``method="fieller"``, a non-finite
|
|
783
|
+
per-threshold variance.
|
|
784
|
+
"""
|
|
785
|
+
if method not in _VALID_METHODS:
|
|
786
|
+
raise ValueError(
|
|
787
|
+
f"ratio_interval: method must be one of {sorted(_VALID_METHODS)}, got {method!r}"
|
|
788
|
+
)
|
|
789
|
+
if dependence == "paired":
|
|
790
|
+
raise ValueError(
|
|
791
|
+
"ratio_interval: dependence='paired' is not implemented in this release; pass "
|
|
792
|
+
"dependence='independent' explicitly to acknowledge independence is being assumed "
|
|
793
|
+
"anyway, or wait for a future release that adds it"
|
|
794
|
+
)
|
|
795
|
+
if dependence not in _VALID_DEPENDENCE:
|
|
796
|
+
raise ValueError(
|
|
797
|
+
"ratio_interval: dependence must be 'independent' (v0.2 will add 'paired'), got "
|
|
798
|
+
f"{dependence!r}"
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
# The shared-`Fit`-object check lives here, in the function, rather than in
|
|
802
|
+
# `Ratio.__post_init__` where the cluster-id check below (delegated to `Ratio`'s own
|
|
803
|
+
# construction) lives. `Ratio`'s constructor does not check it, so that the public type's
|
|
804
|
+
# accepted inputs do not change; every real caller goes through this function (see the
|
|
805
|
+
# matching comment in `Ratio.__post_init__`).
|
|
806
|
+
if dependence == "independent" and t_a.fit is t_b.fit:
|
|
807
|
+
raise ValueError(
|
|
808
|
+
"ratio_interval: threshold_a.fit and threshold_b.fit are the very same Fit object, "
|
|
809
|
+
"which is incompatible with dependence='independent'. This is an object-identity "
|
|
810
|
+
"check only, not a value-equality one: two disjoint samples may legitimately "
|
|
811
|
+
"produce an equal Fit by value, and that ratio is valid."
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
if level is None:
|
|
815
|
+
if t_a.level != t_b.level:
|
|
816
|
+
raise ValueError(
|
|
817
|
+
"ratio_interval: level=None inherits the confidence level from threshold_a and "
|
|
818
|
+
f"threshold_b, which must agree; got threshold_a.level="
|
|
819
|
+
f"{t_a.level!r} and threshold_b.level={t_b.level!r}"
|
|
820
|
+
)
|
|
821
|
+
resolved_level = t_a.level
|
|
822
|
+
else:
|
|
823
|
+
_check_finite_number(level, label="ratio_interval level")
|
|
824
|
+
if not (0.0 < level < 1.0):
|
|
825
|
+
raise ValueError(f"ratio_interval: level must satisfy 0 < level < 1, got {level!r}")
|
|
826
|
+
if level != t_a.level or level != t_b.level:
|
|
827
|
+
raise ValueError(
|
|
828
|
+
f"ratio_interval: level={level!r} does not match threshold_a.level "
|
|
829
|
+
f"({t_a.level!r}) and/or threshold_b.level ({t_b.level!r})"
|
|
830
|
+
)
|
|
831
|
+
resolved_level = level
|
|
832
|
+
|
|
833
|
+
# Carry both inputs' own warnings (for example a "NON_MONOTONE_DATA: " token from either
|
|
834
|
+
# threshold) onto the ratio, on every path below -- the censored/failed early return and the
|
|
835
|
+
# OK+NONE path alike.
|
|
836
|
+
collected_warnings: list[str] = [*t_a.warnings, *t_b.warnings]
|
|
837
|
+
|
|
838
|
+
a_clean = t_a.status is Status.OK and t_a.censoring is Censoring.NONE
|
|
839
|
+
b_clean = t_b.status is Status.OK and t_b.censoring is Censoring.NONE
|
|
840
|
+
if not (a_clean and b_clean):
|
|
841
|
+
propagated_status, propagated_censoring, propagation_warnings = _propagate_censoring(
|
|
842
|
+
t_a, t_b
|
|
843
|
+
)
|
|
844
|
+
collected_warnings.extend(propagation_warnings)
|
|
845
|
+
return Ratio(
|
|
846
|
+
threshold_a=t_a,
|
|
847
|
+
threshold_b=t_b,
|
|
848
|
+
estimate=None,
|
|
849
|
+
lo=None,
|
|
850
|
+
hi=None,
|
|
851
|
+
shape=None,
|
|
852
|
+
method=method,
|
|
853
|
+
dependence=dependence,
|
|
854
|
+
level=resolved_level,
|
|
855
|
+
censoring=propagated_censoring,
|
|
856
|
+
status=propagated_status,
|
|
857
|
+
warnings=tuple(collected_warnings),
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
value_a, value_b = t_a.value, t_b.value
|
|
861
|
+
assert value_a is not None and value_b is not None # `a_clean`/`b_clean` guarantee this
|
|
862
|
+
|
|
863
|
+
# `(Status.OK, Censoring.NONE)` with `lo = hi = None` is a legal `Threshold` -- a converged
|
|
864
|
+
# fit whose profile crossed the target on neither side (`intervals.py`'s own documented
|
|
865
|
+
# "open on both sides" outcome). Nothing else about this function would ever surface that:
|
|
866
|
+
# the delta-method variance used below does not come from `t_a.lo`/`t_a.hi` at all, so a
|
|
867
|
+
# ratio built on such an input looks exactly like an ordinary one unless it is named
|
|
868
|
+
# explicitly here (no silent fallback). `t_a.warnings`/`t_b.warnings` are not repr'd into
|
|
869
|
+
# this message -- `collected_warnings` already starts with both inputs' own warnings in
|
|
870
|
+
# full, so embedding them again here would just duplicate them.
|
|
871
|
+
if t_a.lo is None and t_a.hi is None:
|
|
872
|
+
collected_warnings.append(
|
|
873
|
+
"ratio_interval: threshold_a has status=OK and censoring=NONE but reports no "
|
|
874
|
+
"interval at all (lo and hi are both None); this ratio's variance still comes from "
|
|
875
|
+
"threshold_a's own delta-method covariance"
|
|
876
|
+
)
|
|
877
|
+
if t_b.lo is None and t_b.hi is None:
|
|
878
|
+
collected_warnings.append(
|
|
879
|
+
"ratio_interval: threshold_b has status=OK and censoring=NONE but reports no "
|
|
880
|
+
"interval at all (lo and hi are both None); this ratio's variance still comes from "
|
|
881
|
+
"threshold_b's own delta-method covariance"
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
if method == "log_delta":
|
|
885
|
+
if t_a.axis.scale != "log" or t_b.axis.scale != "log":
|
|
886
|
+
raise ValueError(
|
|
887
|
+
"ratio_interval: method='log_delta' requires threshold_a.axis.scale and "
|
|
888
|
+
"threshold_b.axis.scale to both be 'log', got "
|
|
889
|
+
f"{t_a.axis.scale!r} and {t_b.axis.scale!r}"
|
|
890
|
+
)
|
|
891
|
+
# `axis.scale == "log"` already forces `value > 0` (`Threshold`'s own invariant), so this
|
|
892
|
+
# is redundant given the check above -- kept anyway, both to mirror `Ratio`'s own
|
|
893
|
+
# invariant exactly and to fail explicitly rather than rely on that invariant continuing
|
|
894
|
+
# to hold if it is ever loosened.
|
|
895
|
+
if not (value_a > 0.0 and value_b > 0.0):
|
|
896
|
+
raise ValueError(
|
|
897
|
+
"ratio_interval: method='log_delta' requires threshold_a.value and "
|
|
898
|
+
f"threshold_b.value to both be > 0, got {value_a!r} and {value_b!r}"
|
|
899
|
+
)
|
|
900
|
+
elif not value_b > 0.0:
|
|
901
|
+
# Fieller divides by threshold_b.value. A log axis already guarantees `> 0`; a linear
|
|
902
|
+
# axis only guarantees `>= 0` (`Threshold`'s own invariant), so this is the one case
|
|
903
|
+
# Fieller itself must refuse rather than crash on with a raw ZeroDivisionError.
|
|
904
|
+
raise ValueError(
|
|
905
|
+
f"ratio_interval: threshold_b.value must be > 0 to divide by, got {value_b!r}"
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
z = float(norm.ppf(0.5 + resolved_level / 2.0))
|
|
909
|
+
estimate = value_a / value_b
|
|
910
|
+
|
|
911
|
+
if method == "fieller":
|
|
912
|
+
va = _fieller_variance(t_a, value=value_a)
|
|
913
|
+
vb = _fieller_variance(t_b, value=value_b)
|
|
914
|
+
shape, lo, hi, shape_warnings = _fieller_shape(
|
|
915
|
+
theta_a=value_a, va=va, theta_b=value_b, vb=vb, z=z
|
|
916
|
+
)
|
|
917
|
+
collected_warnings.extend(shape_warnings)
|
|
918
|
+
else:
|
|
919
|
+
# log_delta: both axes are confirmed 'log' above, so `_theta_variance_for` already IS
|
|
920
|
+
# `Var(log(value))` -- no natural-scale detour needed (module docstring).
|
|
921
|
+
var_log_a = _theta_variance_for(t_a)
|
|
922
|
+
var_log_b = _theta_variance_for(t_b)
|
|
923
|
+
lo, hi = _log_delta_shape(
|
|
924
|
+
theta_a=value_a, var_log_a=var_log_a, theta_b=value_b, var_log_b=var_log_b, z=z
|
|
925
|
+
)
|
|
926
|
+
shape = IntervalShape.BOUNDED
|
|
927
|
+
|
|
928
|
+
return Ratio(
|
|
929
|
+
threshold_a=t_a,
|
|
930
|
+
threshold_b=t_b,
|
|
931
|
+
estimate=estimate,
|
|
932
|
+
lo=lo,
|
|
933
|
+
hi=hi,
|
|
934
|
+
shape=shape,
|
|
935
|
+
method=method,
|
|
936
|
+
dependence=dependence,
|
|
937
|
+
level=resolved_level,
|
|
938
|
+
censoring=Censoring.NONE,
|
|
939
|
+
status=Status.OK,
|
|
940
|
+
warnings=tuple(collected_warnings),
|
|
941
|
+
)
|