statwarrant 0.5.0__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.
- statwarrant/__init__.py +38 -0
- statwarrant/_core.py +491 -0
- statwarrant/_precision.py +62 -0
- statwarrant/_ratio_display.py +112 -0
- statwarrant/_version.py +1 -0
- statwarrant/adapters.py +389 -0
- statwarrant/claims.py +2331 -0
- statwarrant/design.py +180 -0
- statwarrant/plan.py +762 -0
- statwarrant-0.5.0.dist-info/METADATA +437 -0
- statwarrant-0.5.0.dist-info/RECORD +13 -0
- statwarrant-0.5.0.dist-info/WHEEL +4 -0
- statwarrant-0.5.0.dist-info/licenses/LICENSE +21 -0
statwarrant/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""statwarrant: what your statistical result warrants you to say.
|
|
2
|
+
|
|
3
|
+
Declare the smallest effect size of interest and its reason, analyse a result,
|
|
4
|
+
and get the claims it supports, the claims it does not, and why.
|
|
5
|
+
|
|
6
|
+
>>> import statwarrant as sw
|
|
7
|
+
>>> plan = sw.declare(sesoi=3, sesoi_source="smallest score gain teachers act on",
|
|
8
|
+
... units="points", scope="first-year students, one university, one term")
|
|
9
|
+
>>> res = plan.analyze(estimate=4.2, se=1.6, df=58)
|
|
10
|
+
>>> res.check("There is no difference between the groups.").level
|
|
11
|
+
'conflict'
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ._core import Evidence, Thresholds, evaluate
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .adapters import AdapterMismatch
|
|
17
|
+
from .claims import CheckResult, Claim, Claims, Part
|
|
18
|
+
from .design import DesignPoint, DesignTable, design_point
|
|
19
|
+
from .plan import Plan, Result, declare
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"declare",
|
|
24
|
+
"Plan",
|
|
25
|
+
"Result",
|
|
26
|
+
"Evidence",
|
|
27
|
+
"Thresholds",
|
|
28
|
+
"Claim",
|
|
29
|
+
"Claims",
|
|
30
|
+
"CheckResult",
|
|
31
|
+
"Part",
|
|
32
|
+
"AdapterMismatch",
|
|
33
|
+
"DesignPoint",
|
|
34
|
+
"DesignTable",
|
|
35
|
+
"design_point",
|
|
36
|
+
"evaluate",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
statwarrant/_core.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""Core inference: difference from zero, equivalence (TOST) and minimal effect (MET).
|
|
2
|
+
|
|
3
|
+
Every decision is read from a confidence interval.
|
|
4
|
+
|
|
5
|
+
* Zero test: the (1 - alpha) interval excludes zero.
|
|
6
|
+
* Minimal-effect test (MET): the (1 - alpha) interval lies entirely beyond one of
|
|
7
|
+
the SESOI bounds. The test can succeed in either direction, so alpha is split
|
|
8
|
+
across the two directions (alpha / 2 per side). This keeps the type I error at
|
|
9
|
+
or below alpha for every true effect inside the bounds, under both policies.
|
|
10
|
+
* Equivalence test (TOST, Schuirmann 1987): the interval lies entirely inside
|
|
11
|
+
the SESOI bounds. Both one-sided tests must reject (an intersection-union
|
|
12
|
+
test), so each may run at the full per-side level. The interval policy
|
|
13
|
+
decides which level:
|
|
14
|
+
|
|
15
|
+
- ``"single"`` (default): alpha / 2 per side, i.e. the same (1 - alpha)
|
|
16
|
+
interval as every other decision. One picture, no contradictions,
|
|
17
|
+
slightly less power for equivalence.
|
|
18
|
+
- ``"standard"``: alpha per side, i.e. the (1 - 2 alpha) interval, as in
|
|
19
|
+
Schuirmann's TOST and the TOSTER package. When equivalence is established
|
|
20
|
+
with that interval but the (1 - alpha) interval reaches outside the bounds,
|
|
21
|
+
the result reports a level conflict.
|
|
22
|
+
|
|
23
|
+
Version 0.1.0 also ran the minimal-effect test at alpha per side under
|
|
24
|
+
``"standard"``; its type I error could then approach 2 alpha. That was removed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
import struct
|
|
31
|
+
import sys
|
|
32
|
+
import warnings
|
|
33
|
+
from dataclasses import dataclass, field, replace
|
|
34
|
+
from typing import Optional, Tuple
|
|
35
|
+
|
|
36
|
+
from scipy import integrate, special, stats
|
|
37
|
+
|
|
38
|
+
from ._precision import checked_float
|
|
39
|
+
|
|
40
|
+
POLICIES = ("single", "standard")
|
|
41
|
+
MIN_ALPHA = 1e-10
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def level_text(level: float) -> str:
|
|
45
|
+
"""An interval level as a percentage with the digits it needs (95%, 99.9%, 99.99999%)."""
|
|
46
|
+
return f"{level * 100:.10g}%"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dist(df: Optional[float]):
|
|
50
|
+
"""Reference distribution: Student t with ``df`` degrees of freedom, or normal."""
|
|
51
|
+
if df is None:
|
|
52
|
+
return stats.norm
|
|
53
|
+
try:
|
|
54
|
+
df = float(df)
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
raise ValueError(f"df must be a positive number or None, got {df!r}") from None
|
|
57
|
+
if math.isnan(df):
|
|
58
|
+
raise ValueError("df must be a positive number or None, got nan")
|
|
59
|
+
if math.isinf(df) and df > 0:
|
|
60
|
+
return stats.norm
|
|
61
|
+
if not df > 0:
|
|
62
|
+
raise ValueError(f"df must be positive, got {df!r}")
|
|
63
|
+
return stats.t(df)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _log_t_tail(x: float, df: float) -> float:
|
|
67
|
+
"""Log one-sided Student t tail, including when SciPy's sf/logsf underflow.
|
|
68
|
+
|
|
69
|
+
Integrate the density on a log-distance scale, factoring out its value at x.
|
|
70
|
+
This avoids forming x*x, the tiny tail, or a huge unscaled integral. It is
|
|
71
|
+
used only in the subnormal/zero tail region, not for ordinary decisions.
|
|
72
|
+
"""
|
|
73
|
+
if not (math.isfinite(x) and x > 1 and math.isfinite(df) and df > 0):
|
|
74
|
+
return -math.inf
|
|
75
|
+
try:
|
|
76
|
+
ratio = math.sqrt(df) / x
|
|
77
|
+
w = 1.0 / (1.0 + ratio * ratio)
|
|
78
|
+
k = (df + 1.0) * w - 1.0
|
|
79
|
+
if not (0 < w <= 1 and math.isfinite(k) and k > 0):
|
|
80
|
+
return -math.inf
|
|
81
|
+
r = 2 * math.log(x) - math.log(df)
|
|
82
|
+
log1pr = math.log1p(math.exp(r)) if r < 0 else r + math.log1p(math.exp(-r))
|
|
83
|
+
logpdf = -math.log(df) / 2 - float(special.betaln(0.5, df / 2)) - (df / 2 + 0.5) * log1pr
|
|
84
|
+
|
|
85
|
+
def integrand(u):
|
|
86
|
+
v = u / k
|
|
87
|
+
if v < 300:
|
|
88
|
+
delta = math.log1p(w * math.expm1(2 * v))
|
|
89
|
+
else:
|
|
90
|
+
delta = 2 * v + math.log(w + (1 - w) * math.exp(-2 * v))
|
|
91
|
+
exponent = v - (df / 2 + 0.5) * delta
|
|
92
|
+
return math.exp(exponent) if exponent > -746 else 0.0
|
|
93
|
+
|
|
94
|
+
with warnings.catch_warnings():
|
|
95
|
+
warnings.simplefilter("error", integrate.IntegrationWarning)
|
|
96
|
+
integral, error = integrate.quad(integrand, 0, math.inf, epsabs=1e-11, epsrel=1e-11, limit=150)
|
|
97
|
+
if not (math.isfinite(integral) and integral > 0 and error <= 1e-9 * integral):
|
|
98
|
+
return -math.inf
|
|
99
|
+
result = logpdf + math.log(x) - math.log(k) + math.log(integral)
|
|
100
|
+
return result if math.isfinite(result) and result <= 0 else -math.inf
|
|
101
|
+
except (ArithmeticError, ValueError, integrate.IntegrationWarning):
|
|
102
|
+
return -math.inf
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _log_tail(dist, x: float, df: Optional[float]) -> float:
|
|
106
|
+
"""Log sf, with a fallback where SciPy's Student t logsf loses the tail."""
|
|
107
|
+
log_sf = float(dist.logsf(x))
|
|
108
|
+
if df is not None and (not math.isfinite(log_sf) or log_sf < math.log(sys.float_info.min)):
|
|
109
|
+
recovered = _log_t_tail(x, df)
|
|
110
|
+
if math.isfinite(recovered):
|
|
111
|
+
return recovered
|
|
112
|
+
return log_sf
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _tail_p(dist, x: float, df: Optional[float], two_sided: bool = False) -> float:
|
|
116
|
+
"""Use ordinary sf except in the extreme tail; multiply on the log scale there."""
|
|
117
|
+
sf = float(dist.sf(x))
|
|
118
|
+
if sf >= sys.float_info.min:
|
|
119
|
+
return min(1.0, sf * (2 if two_sided else 1))
|
|
120
|
+
log_sf = _log_tail(dist, x, df)
|
|
121
|
+
if math.isfinite(log_sf):
|
|
122
|
+
return min(1.0, math.exp(log_sf + (math.log(2.0) if two_sided else 0.0)))
|
|
123
|
+
# The log tail may itself be unavailable; retain any nonzero SciPy result.
|
|
124
|
+
return min(1.0, sf * (2 if two_sided else 1))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _critical_from_tail(tail: float, df: Optional[float]) -> float:
|
|
128
|
+
"""Refuse a critical value if the distribution cannot invert its own tail.
|
|
129
|
+
|
|
130
|
+
Some SciPy versions return a finite but grossly inaccurate Student-t isf
|
|
131
|
+
for extraordinarily small df. A self-consistency check prevents reporting
|
|
132
|
+
an inference based on that result; it is not proof of general accuracy.
|
|
133
|
+
"""
|
|
134
|
+
dist = _dist(df)
|
|
135
|
+
c = float(dist.isf(tail))
|
|
136
|
+
if not math.isfinite(c) or c < 0:
|
|
137
|
+
raise ValueError("the reference distribution's critical value is not finite; check df and alpha")
|
|
138
|
+
observed_tail = float(dist.sf(c))
|
|
139
|
+
if not math.isfinite(observed_tail) or not math.isclose(observed_tail, tail, rel_tol=1e-6, abs_tol=0.0):
|
|
140
|
+
raise ValueError("the reference distribution's critical value cannot be verified for this df and alpha")
|
|
141
|
+
return c
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def critical_value(two_sided_level: float, df: Optional[float]) -> float:
|
|
145
|
+
"""Critical value for a two-sided interval at ``two_sided_level`` (e.g. 0.95)."""
|
|
146
|
+
if not 0 < two_sided_level < 1:
|
|
147
|
+
raise ValueError("interval level must be between 0 and 1")
|
|
148
|
+
return _critical_from_tail((1 - two_sided_level) / 2, df)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def interval(estimate: float, se: float, level: float, df: Optional[float]) -> Tuple[float, float]:
|
|
152
|
+
c = critical_value(level, df)
|
|
153
|
+
return (estimate - c * se, estimate + c * se)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _interval_tail(estimate: float, se: float, tail: float, df: Optional[float]) -> Tuple[float, float]:
|
|
157
|
+
"""Interval whose two tails each hold ``tail``; uses the survival function, stable for tiny alpha."""
|
|
158
|
+
c = _critical_from_tail(tail, df)
|
|
159
|
+
return (estimate - c * se, estimate + c * se)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class Thresholds:
|
|
164
|
+
"""The decision for every symmetric threshold (-d, d) at once.
|
|
165
|
+
|
|
166
|
+
Both tests are read from intervals, so the whole sensitivity to the choice of
|
|
167
|
+
threshold is fixed by two numbers:
|
|
168
|
+
|
|
169
|
+
* ``met_below``: an effect beyond +/-d is established for every positive
|
|
170
|
+
representable d below this value; None when the zero test is not established.
|
|
171
|
+
* ``equivalence_above``: equivalence within +/-d is established for every d
|
|
172
|
+
above this value, among positive finite representable thresholds.
|
|
173
|
+
|
|
174
|
+
Both cut-offs are strict: at a positive finite cut-off the decision is
|
|
175
|
+
"undetermined", and between the cut-offs neither is established. At the
|
|
176
|
+
domain limits the open side can be empty: ``met_below`` can be the smallest
|
|
177
|
+
positive float, or ``equivalence_above`` the largest finite float. Conversely,
|
|
178
|
+
``equivalence_above`` can be zero if all positive finite floats are equivalent;
|
|
179
|
+
zero is not itself a valid input to :meth:`decision`. This re-reads the same
|
|
180
|
+
intervals; it is not a family of new tests, and choosing a threshold after
|
|
181
|
+
seeing these numbers is not a confirmatory claim.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
met_below: Optional[float]
|
|
185
|
+
met_level: float
|
|
186
|
+
equivalence_above: float
|
|
187
|
+
equivalence_level: float
|
|
188
|
+
# The same arithmetic as evaluate(), so decision(d) always agrees with it
|
|
189
|
+
estimate: float = field(default=0.0, repr=False)
|
|
190
|
+
se: float = field(default=1.0, repr=False)
|
|
191
|
+
met_critical: float = field(default=math.inf, repr=False)
|
|
192
|
+
equivalence_critical: float = field(default=math.inf, repr=False)
|
|
193
|
+
|
|
194
|
+
def decision(self, d: float) -> str:
|
|
195
|
+
""""exceeds", "equivalent" or "undetermined" for the symmetric threshold (-d, d).
|
|
196
|
+
|
|
197
|
+
Computed like evaluate(): the bound is subtracted from the estimate before
|
|
198
|
+
dividing by the standard error, which keeps precision near a cut-off.
|
|
199
|
+
"""
|
|
200
|
+
d = checked_float(d, "threshold")
|
|
201
|
+
if not (math.isfinite(d) and d > 0):
|
|
202
|
+
raise ValueError("a threshold must be a finite positive number")
|
|
203
|
+
if (abs(self.estimate) - d) / self.se > self.met_critical:
|
|
204
|
+
return "exceeds"
|
|
205
|
+
if (d - abs(self.estimate)) / self.se > self.equivalence_critical:
|
|
206
|
+
return "equivalent"
|
|
207
|
+
return "undetermined"
|
|
208
|
+
|
|
209
|
+
def to_dict(self) -> dict:
|
|
210
|
+
return {
|
|
211
|
+
"met_below": self.met_below,
|
|
212
|
+
"met_interval_level": self.met_level,
|
|
213
|
+
"equivalence_above": self.equivalence_above,
|
|
214
|
+
"equivalence_interval_level": self.equivalence_level,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True)
|
|
219
|
+
class Evidence:
|
|
220
|
+
"""What the data say about the effect, under the declared plan.
|
|
221
|
+
|
|
222
|
+
Attributes are plain numbers and booleans so the object can be serialised
|
|
223
|
+
and tested. ``claim_class`` is the label the claim contract is built on.
|
|
224
|
+
p-values for the equivalence and minimal-effect tests are one-sided and are
|
|
225
|
+
compared with the per-side thresholds reported next to them.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
estimate: float
|
|
229
|
+
se: float
|
|
230
|
+
df: Optional[float]
|
|
231
|
+
alpha: float
|
|
232
|
+
policy: str
|
|
233
|
+
sesoi: Optional[Tuple[float, float]]
|
|
234
|
+
|
|
235
|
+
zero_level: float
|
|
236
|
+
zero_interval: Tuple[float, float]
|
|
237
|
+
p_zero: float
|
|
238
|
+
differs_from_zero: bool
|
|
239
|
+
|
|
240
|
+
equivalence_level: Optional[float]
|
|
241
|
+
equivalence_interval: Optional[Tuple[float, float]]
|
|
242
|
+
equivalence_threshold: Optional[float]
|
|
243
|
+
p_equivalence: Optional[float]
|
|
244
|
+
equivalent: Optional[bool]
|
|
245
|
+
|
|
246
|
+
met_level: Optional[float]
|
|
247
|
+
met_interval: Optional[Tuple[float, float]]
|
|
248
|
+
met_threshold: Optional[float]
|
|
249
|
+
p_minimal_effect: Optional[float]
|
|
250
|
+
exceeds_sesoi: Optional[bool]
|
|
251
|
+
|
|
252
|
+
claim_class: str
|
|
253
|
+
level_conflict: bool
|
|
254
|
+
|
|
255
|
+
def thresholds(self) -> Thresholds:
|
|
256
|
+
"""Decisions for every symmetric threshold at once (see :class:`Thresholds`)."""
|
|
257
|
+
lo, hi = self.zero_interval
|
|
258
|
+
met_below = min(abs(lo), abs(hi)) if self.differs_from_zero else None
|
|
259
|
+
eq_tail = self.alpha / 2 if self.policy == "single" else self.alpha
|
|
260
|
+
eq_level = 1 - 2 * eq_tail
|
|
261
|
+
elo, ehi = _interval_tail(self.estimate, self.se, eq_tail, self.df)
|
|
262
|
+
th = Thresholds(met_below, self.zero_level, max(abs(elo), abs(ehi)), eq_level,
|
|
263
|
+
self.estimate, self.se, _critical_from_tail(self.alpha / 2, self.df),
|
|
264
|
+
_critical_from_tail(eq_tail, self.df))
|
|
265
|
+
# Interval and test-statistic arithmetic can diverge by many floats near
|
|
266
|
+
# cancellation. Find the exact transitions of decision(), not just nearby
|
|
267
|
+
# interval endpoints, so the printed cut-offs cannot contradict it.
|
|
268
|
+
return replace(th, met_below=None if met_below is None else _edge(th, met_below, "exceeds", below=True),
|
|
269
|
+
equivalence_above=_edge(th, th.equivalence_above, "equivalent", below=False))
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def direction(self) -> int:
|
|
273
|
+
"""+1 or -1 when the difference from zero is established, otherwise 0."""
|
|
274
|
+
if not self.differs_from_zero:
|
|
275
|
+
return 0
|
|
276
|
+
return 1 if self.estimate > 0 else -1
|
|
277
|
+
|
|
278
|
+
def to_dict(self) -> dict:
|
|
279
|
+
out = {
|
|
280
|
+
"estimate": self.estimate,
|
|
281
|
+
"se": self.se,
|
|
282
|
+
"df": self.df,
|
|
283
|
+
"alpha": self.alpha,
|
|
284
|
+
"interval_policy": self.policy,
|
|
285
|
+
"sesoi": list(self.sesoi) if self.sesoi else None,
|
|
286
|
+
"zero_test": {
|
|
287
|
+
"interval_level": self.zero_level,
|
|
288
|
+
"interval": list(self.zero_interval),
|
|
289
|
+
"p_value": self.p_zero,
|
|
290
|
+
"differs_from_zero": self.differs_from_zero,
|
|
291
|
+
},
|
|
292
|
+
"equivalence_test": None,
|
|
293
|
+
"minimal_effect_test": None,
|
|
294
|
+
"claim_class": self.claim_class,
|
|
295
|
+
"level_conflict": self.level_conflict,
|
|
296
|
+
}
|
|
297
|
+
if self.p_zero == 0.0:
|
|
298
|
+
out["zero_test"]["p_value_underflow_or_unavailable"] = True
|
|
299
|
+
if self.sesoi is not None:
|
|
300
|
+
out["equivalence_test"] = {
|
|
301
|
+
"interval_level": self.equivalence_level,
|
|
302
|
+
"interval": list(self.equivalence_interval),
|
|
303
|
+
"per_side_threshold": self.equivalence_threshold,
|
|
304
|
+
"p_value": self.p_equivalence,
|
|
305
|
+
"equivalent": self.equivalent,
|
|
306
|
+
}
|
|
307
|
+
out["minimal_effect_test"] = {
|
|
308
|
+
"interval_level": self.met_level,
|
|
309
|
+
"interval": list(self.met_interval),
|
|
310
|
+
"per_side_threshold": self.met_threshold,
|
|
311
|
+
"p_value": self.p_minimal_effect,
|
|
312
|
+
"exceeds_sesoi": self.exceeds_sesoi,
|
|
313
|
+
}
|
|
314
|
+
if self.p_equivalence == 0.0:
|
|
315
|
+
out["equivalence_test"]["p_value_underflow_or_unavailable"] = True
|
|
316
|
+
if self.p_minimal_effect == 0.0:
|
|
317
|
+
out["minimal_effect_test"]["p_value_underflow_or_unavailable"] = True
|
|
318
|
+
return out
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
_MAX_FINITE_BITS = 0x7FEFFFFFFFFFFFFF
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _positive_float(bits: int) -> float:
|
|
325
|
+
"""Decode a nonnegative finite IEEE-754 binary64 by its ordered bit pattern."""
|
|
326
|
+
return struct.unpack(">d", struct.pack(">Q", bits))[0]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _edge(th: "Thresholds", start: float, label: str, below: bool) -> float:
|
|
330
|
+
"""Find the strict boundary on the positive finite binary64 domain.
|
|
331
|
+
|
|
332
|
+
For MET, the labelled region is a prefix; return its first non-labelled
|
|
333
|
+
float. For equivalence, the labelled region is a suffix; return its last
|
|
334
|
+
non-labelled float. Rounded subtraction and division by a positive SE are
|
|
335
|
+
monotone, so both predicates are monotone on this domain. A zero sentinel
|
|
336
|
+
represents an empty non-labelled prefix; the largest finite float represents
|
|
337
|
+
an empty labelled suffix. No decision is ever requested for zero or infinity.
|
|
338
|
+
"""
|
|
339
|
+
# Most interval-derived guesses are already adjacent to the transition.
|
|
340
|
+
# Check the invariant, rather than assuming a small rounding discrepancy.
|
|
341
|
+
if math.isfinite(start) and start >= 0:
|
|
342
|
+
if start > 0 and th.decision(start) == label:
|
|
343
|
+
candidate = math.nextafter(start, math.inf if below else 0.0)
|
|
344
|
+
if candidate == 0.0 and not below:
|
|
345
|
+
return 0.0
|
|
346
|
+
if math.isfinite(candidate) and candidate > 0 and th.decision(candidate) != label:
|
|
347
|
+
return candidate
|
|
348
|
+
elif start > 0 or not below:
|
|
349
|
+
neighbor = math.nextafter(start, 0.0 if below else math.inf)
|
|
350
|
+
if (below and neighbor == 0.0) or (not below and not math.isfinite(neighbor)):
|
|
351
|
+
return start
|
|
352
|
+
if neighbor > 0 and math.isfinite(neighbor) and th.decision(neighbor) == label:
|
|
353
|
+
return start
|
|
354
|
+
|
|
355
|
+
# Nonnegative binary64 encodings have the same order as their values. For
|
|
356
|
+
# MET, lo is labelled (or the zero sentinel), and hi is not labelled: max
|
|
357
|
+
# is never labelled because abs(estimate) <= max. For equivalence, lo is
|
|
358
|
+
# unlabelled (or zero), and hi is labelled (or the MAX+1 sentinel). The
|
|
359
|
+
# sentinels are never passed to decision. Each iteration maintains those
|
|
360
|
+
# invariants and halves an integer range smaller than 2**63; 63 suffice.
|
|
361
|
+
lo, hi = (0, _MAX_FINITE_BITS) if below else (0, _MAX_FINITE_BITS + 1)
|
|
362
|
+
for _ in range(63):
|
|
363
|
+
if hi - lo == 1:
|
|
364
|
+
return _positive_float(hi if below else lo)
|
|
365
|
+
mid = (lo + hi) // 2
|
|
366
|
+
is_labelled = th.decision(_positive_float(mid)) == label
|
|
367
|
+
if is_labelled == below:
|
|
368
|
+
lo = mid
|
|
369
|
+
else:
|
|
370
|
+
hi = mid
|
|
371
|
+
if hi - lo == 1:
|
|
372
|
+
return _positive_float(hi if below else lo)
|
|
373
|
+
raise RuntimeError("could not establish a finite threshold boundary")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def evaluate(
|
|
377
|
+
estimate: float,
|
|
378
|
+
se: float,
|
|
379
|
+
df: Optional[float],
|
|
380
|
+
alpha: float,
|
|
381
|
+
policy: str,
|
|
382
|
+
sesoi: Optional[Tuple[float, float]],
|
|
383
|
+
) -> Evidence:
|
|
384
|
+
"""Run the three tests and classify the result."""
|
|
385
|
+
if policy not in POLICIES:
|
|
386
|
+
raise ValueError(f"interval_policy must be one of {POLICIES}, got {policy!r}")
|
|
387
|
+
estimate = checked_float(estimate, "estimate")
|
|
388
|
+
se = checked_float(se, "se")
|
|
389
|
+
df = None if df is None else checked_float(df, "df")
|
|
390
|
+
if sesoi is not None:
|
|
391
|
+
low, high = sesoi
|
|
392
|
+
sesoi = (checked_float(low, "sesoi lower bound"), checked_float(high, "sesoi upper bound"))
|
|
393
|
+
if not (math.isfinite(estimate) and math.isfinite(se)):
|
|
394
|
+
raise ValueError("estimate and se must be finite numbers")
|
|
395
|
+
if se <= 0:
|
|
396
|
+
raise ValueError("se must be strictly positive")
|
|
397
|
+
if not MIN_ALPHA <= alpha < 0.5:
|
|
398
|
+
raise ValueError(f"alpha must be at least {MIN_ALPHA:g} and below 0.5")
|
|
399
|
+
if df is not None and math.isinf(float(df)) and float(df) > 0:
|
|
400
|
+
df = None # infinite degrees of freedom: the normal reference
|
|
401
|
+
if sesoi is not None:
|
|
402
|
+
low, high = sesoi
|
|
403
|
+
if not (math.isfinite(low) and math.isfinite(high) and low < 0 < high):
|
|
404
|
+
raise ValueError("sesoi bounds must be finite with low < 0 < high")
|
|
405
|
+
|
|
406
|
+
# Every decision is made from a test statistic in which the bound is subtracted
|
|
407
|
+
# before dividing by the standard error; intervals are for reporting. This keeps a
|
|
408
|
+
# decision and its p-value in agreement even when estimate and bound are large and close.
|
|
409
|
+
dist = _dist(df)
|
|
410
|
+
zero_level = 1 - alpha
|
|
411
|
+
c_zero = _critical_from_tail(alpha / 2, df)
|
|
412
|
+
zero_iv = _interval_tail(estimate, se, alpha / 2, df)
|
|
413
|
+
p_zero = _tail_p(dist, abs(estimate) / se, df, two_sided=True)
|
|
414
|
+
differs = abs(estimate) / se > c_zero
|
|
415
|
+
if not all(math.isfinite(v) for v in zero_iv):
|
|
416
|
+
raise ValueError("the estimate or standard error is too large for floating point; rescale the outcome")
|
|
417
|
+
|
|
418
|
+
if sesoi is None:
|
|
419
|
+
if differs and estimate > 0:
|
|
420
|
+
cls = "differs_positive"
|
|
421
|
+
elif differs:
|
|
422
|
+
cls = "differs_negative"
|
|
423
|
+
else:
|
|
424
|
+
cls = "inconclusive_no_sesoi"
|
|
425
|
+
return Evidence(
|
|
426
|
+
estimate, se, df, alpha, policy, None,
|
|
427
|
+
zero_level, zero_iv, p_zero, differs,
|
|
428
|
+
None, None, None, None, None,
|
|
429
|
+
None, None, None, None, None,
|
|
430
|
+
cls, False,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
low, high = sesoi
|
|
434
|
+
|
|
435
|
+
# Equivalence (TOST): reject both H0: theta <= low and H0: theta >= high.
|
|
436
|
+
eq_threshold = alpha / 2 if policy == "single" else alpha
|
|
437
|
+
eq_level = 1 - 2 * eq_threshold
|
|
438
|
+
eq_iv = _interval_tail(estimate, se, eq_threshold, df)
|
|
439
|
+
c_eq = _critical_from_tail(eq_threshold, df)
|
|
440
|
+
z_low, z_high = (estimate - low) / se, (high - estimate) / se
|
|
441
|
+
if not (math.isfinite(z_low) and math.isfinite(z_high) and all(math.isfinite(v) for v in eq_iv)):
|
|
442
|
+
raise ValueError("the inputs are too large for floating point; rescale the outcome and the SESOI")
|
|
443
|
+
p_eq = max(_tail_p(dist, z_low, df), _tail_p(dist, z_high, df))
|
|
444
|
+
equivalent = z_low > c_eq and z_high > c_eq
|
|
445
|
+
|
|
446
|
+
# Minimal effect (MET): reject H0: low <= theta <= high in either direction,
|
|
447
|
+
# alpha / 2 per direction, i.e. the (1 - alpha) interval beyond a bound.
|
|
448
|
+
met_threshold = alpha / 2
|
|
449
|
+
met_level = 1 - alpha
|
|
450
|
+
met_iv = zero_iv
|
|
451
|
+
p_met = min(_tail_p(dist, -z_high, df), _tail_p(dist, -z_low, df))
|
|
452
|
+
met_pos = -z_high > c_zero
|
|
453
|
+
met_neg = -z_low > c_zero
|
|
454
|
+
exceeds = met_pos or met_neg
|
|
455
|
+
|
|
456
|
+
# Wording only: an interval that passes a bound by floating-point noise
|
|
457
|
+
# (e.g. ppf versus isf in the last digit) touches it rather than extends beyond it.
|
|
458
|
+
# Each side uses its own scale, so a huge lower bound cannot hide an upper crossing.
|
|
459
|
+
past_low = zero_iv[0] < low - 1e-12 * max(abs(low), se)
|
|
460
|
+
past_high = zero_iv[1] > high + 1e-12 * max(abs(high), se)
|
|
461
|
+
if met_pos:
|
|
462
|
+
cls = "exceeds_sesoi_positive"
|
|
463
|
+
elif met_neg:
|
|
464
|
+
cls = "exceeds_sesoi_negative"
|
|
465
|
+
elif equivalent and differs:
|
|
466
|
+
cls = "nonzero_negligible"
|
|
467
|
+
elif equivalent:
|
|
468
|
+
cls = "equivalent"
|
|
469
|
+
elif differs and estimate > 0:
|
|
470
|
+
cls = "positive_importance_undetermined"
|
|
471
|
+
elif differs:
|
|
472
|
+
cls = "negative_importance_undetermined"
|
|
473
|
+
elif past_low and past_high:
|
|
474
|
+
cls = "inconclusive_both"
|
|
475
|
+
elif past_low or past_high:
|
|
476
|
+
cls = "inconclusive_one_side"
|
|
477
|
+
else:
|
|
478
|
+
# The interval touches a bound without extending beyond it.
|
|
479
|
+
cls = "inconclusive_at_bound"
|
|
480
|
+
|
|
481
|
+
# Only under "standard": equivalence read from the (1 - 2 alpha) interval
|
|
482
|
+
# while the (1 - alpha) interval reaches outside the bounds.
|
|
483
|
+
conflict = bool(equivalent and not (z_low > c_zero and z_high > c_zero))
|
|
484
|
+
|
|
485
|
+
return Evidence(
|
|
486
|
+
estimate, se, df, alpha, policy, (low, high),
|
|
487
|
+
zero_level, zero_iv, p_zero, differs,
|
|
488
|
+
eq_level, eq_iv, eq_threshold, p_eq, equivalent,
|
|
489
|
+
met_level, met_iv, met_threshold, p_met, exceeds,
|
|
490
|
+
cls, conflict,
|
|
491
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Conservative checks at boundaries that convert numeric inputs to float."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from decimal import Decimal, InvalidOperation
|
|
5
|
+
from numbers import Integral, Rational
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def checked_float(value, name):
|
|
11
|
+
"""Convert to float without silently narrowing an exact integral input.
|
|
12
|
+
|
|
13
|
+
Fractional inputs may undergo the ordinary rounding inherent in binary64.
|
|
14
|
+
A float that was rounded before reaching this function cannot be recovered.
|
|
15
|
+
"""
|
|
16
|
+
if type(value) is float:
|
|
17
|
+
return value
|
|
18
|
+
try:
|
|
19
|
+
result = float(value)
|
|
20
|
+
except OverflowError as exc:
|
|
21
|
+
raise ValueError(f"{name} is too large to represent as a finite float; rescale the inputs") from exc
|
|
22
|
+
|
|
23
|
+
# Decimal strings have long been accepted by explicit numeric inputs. Treat
|
|
24
|
+
# integral strings like their Decimal equivalents, without requiring exact
|
|
25
|
+
# representation of ordinary non-integral decimal values.
|
|
26
|
+
original = value
|
|
27
|
+
if isinstance(original, np.ndarray) and original.size == 1:
|
|
28
|
+
original = original.item()
|
|
29
|
+
if isinstance(value, str):
|
|
30
|
+
try:
|
|
31
|
+
original = Decimal(value)
|
|
32
|
+
except InvalidOperation:
|
|
33
|
+
return result # float() above remains the authority on accepted syntax
|
|
34
|
+
|
|
35
|
+
if isinstance(original, Integral):
|
|
36
|
+
exact_integer = int(original)
|
|
37
|
+
elif isinstance(original, Decimal):
|
|
38
|
+
if not original.is_finite():
|
|
39
|
+
return result
|
|
40
|
+
if not math.isfinite(result):
|
|
41
|
+
raise ValueError(f"{name} is too large to represent as a finite float; rescale the inputs")
|
|
42
|
+
if original == original.to_integral_value() and original != Decimal.from_float(result):
|
|
43
|
+
raise ValueError(f"{name} is an integer that cannot be represented exactly as a float; rescale the inputs")
|
|
44
|
+
return result
|
|
45
|
+
elif isinstance(original, Rational):
|
|
46
|
+
if original.denominator != 1:
|
|
47
|
+
return result
|
|
48
|
+
exact_integer = original.numerator
|
|
49
|
+
elif isinstance(original, np.floating):
|
|
50
|
+
if not np.isfinite(original):
|
|
51
|
+
return result
|
|
52
|
+
if not math.isfinite(result):
|
|
53
|
+
raise ValueError(f"{name} is too large to represent as a finite float; rescale the inputs")
|
|
54
|
+
if original != np.trunc(original):
|
|
55
|
+
return result
|
|
56
|
+
exact_integer = int(original)
|
|
57
|
+
else:
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
if not math.isfinite(result) or int(result) != exact_integer:
|
|
61
|
+
raise ValueError(f"{name} is an integer that cannot be represented exactly as a float; rescale the inputs")
|
|
62
|
+
return result
|