tf-inference 0.1.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.
- tf_inference/__init__.py +55 -0
- tf_inference/calibration.py +228 -0
- tf_inference/cli.py +57 -0
- tf_inference/curve.py +195 -0
- tf_inference/data/calibrations.json +4 -0
- tf_inference/data/calibrations.parquet +0 -0
- tf_inference/probability.py +227 -0
- tf_inference/py.typed +0 -0
- tf_inference/tables.py +387 -0
- tf_inference-0.1.0.dist-info/METADATA +301 -0
- tf_inference-0.1.0.dist-info/RECORD +14 -0
- tf_inference-0.1.0.dist-info/WHEEL +4 -0
- tf_inference-0.1.0.dist-info/entry_points.txt +3 -0
- tf_inference-0.1.0.dist-info/licenses/LICENSE +21 -0
tf_inference/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Numerically constructed tF inference for single-instrument IV models."""
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
|
|
5
|
+
from .calibration import Calibration, CalibrationDiagnostics, calibrate
|
|
6
|
+
from .curve import ConvergenceError, Curve, construct_curve, validate_alpha
|
|
7
|
+
from .probability import RejectionProbability, rejection_probability
|
|
8
|
+
from .tables import available_alphas, export_table, load_table
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Calibration",
|
|
12
|
+
"CalibrationDiagnostics",
|
|
13
|
+
"ConvergenceError",
|
|
14
|
+
"Curve",
|
|
15
|
+
"RejectionProbability",
|
|
16
|
+
"available_alphas",
|
|
17
|
+
"calibrate",
|
|
18
|
+
"confidence_interval",
|
|
19
|
+
"construct_curve",
|
|
20
|
+
"critical_value",
|
|
21
|
+
"export_table",
|
|
22
|
+
"load_table",
|
|
23
|
+
"rejection_probability",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@lru_cache(maxsize=32)
|
|
28
|
+
def _computed_calibration(alpha):
|
|
29
|
+
return calibrate(alpha)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _calibration(alpha, method):
|
|
33
|
+
if method == "table":
|
|
34
|
+
return load_table(alpha)
|
|
35
|
+
if method == "compute":
|
|
36
|
+
return _computed_calibration(alpha)
|
|
37
|
+
raise ValueError("method must be 'table' or 'compute'.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def critical_value(f_stat, alpha=0.05, *, method="table"):
|
|
41
|
+
"""Return tF critical values for absolute t using a table or numerical construction.
|
|
42
|
+
|
|
43
|
+
The default requires an exact matching bundled alpha. Explicitly select
|
|
44
|
+
``method="compute"`` for cached numerical calibration at another level.
|
|
45
|
+
No interpolation across significance levels is performed.
|
|
46
|
+
For custom numerical tolerances use ``calibrate`` and inspect its diagnostics.
|
|
47
|
+
"""
|
|
48
|
+
return _calibration(validate_alpha(alpha), method).critical_value(f_stat)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def confidence_interval(coefficient, se, f_stat, alpha=0.05, *, method="table"):
|
|
52
|
+
"""Return tF confidence intervals with lower/upper bounds on the last axis."""
|
|
53
|
+
return _calibration(validate_alpha(alpha), method).confidence_interval(
|
|
54
|
+
coefficient, se, f_stat
|
|
55
|
+
)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Numerical plateau selection, audit records, and inference."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from math import isfinite
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import ArrayLike
|
|
9
|
+
from scipy.optimize import OptimizeResult, brentq, minimize, minimize_scalar
|
|
10
|
+
|
|
11
|
+
from .curve import ConvergenceError, Curve, construct_curve
|
|
12
|
+
from .probability import rejection_probability
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class CalibrationDiagnostics:
|
|
17
|
+
"""Finite numerical size search; this is not a uniform mathematical bound."""
|
|
18
|
+
|
|
19
|
+
alpha: float
|
|
20
|
+
plateau_f: float
|
|
21
|
+
plateau_critical: float
|
|
22
|
+
property_c_endpoint: float
|
|
23
|
+
maximum_rejection: float
|
|
24
|
+
worst_strength: float
|
|
25
|
+
worst_rho: float
|
|
26
|
+
integration_error: float
|
|
27
|
+
size_tolerance: float
|
|
28
|
+
strength_limit: float
|
|
29
|
+
evaluations: int
|
|
30
|
+
adjustments: int
|
|
31
|
+
correlations: tuple[float, ...]
|
|
32
|
+
strengths: tuple[float, ...]
|
|
33
|
+
|
|
34
|
+
def to_dict(self):
|
|
35
|
+
"""Return serializable diagnostics."""
|
|
36
|
+
return asdict(self)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Calibration:
|
|
41
|
+
"""A tF curve and a plateau checked by a finite numerical size search."""
|
|
42
|
+
|
|
43
|
+
curve: Curve
|
|
44
|
+
plateau_f: float
|
|
45
|
+
diagnostics: CalibrationDiagnostics
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def alpha(self):
|
|
49
|
+
"""Two-sided significance level."""
|
|
50
|
+
return self.curve.alpha
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def boundary(self):
|
|
54
|
+
"""F at or below which the confidence interval is unbounded."""
|
|
55
|
+
return self.curve.boundary
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def plateau(self):
|
|
59
|
+
"""Critical value for absolute t on the plateau."""
|
|
60
|
+
return float(self.curve.critical(self.plateau_f))
|
|
61
|
+
|
|
62
|
+
def critical_value(self, f_stat: ArrayLike):
|
|
63
|
+
"""Evaluate critical values, with infinity at and below the boundary."""
|
|
64
|
+
if np.iscomplexobj(f_stat):
|
|
65
|
+
raise ValueError("F must be real.")
|
|
66
|
+
values = np.asarray(f_stat, dtype=float)
|
|
67
|
+
if values.ndim == 0:
|
|
68
|
+
value = float(values)
|
|
69
|
+
if not isfinite(value) or value < 0:
|
|
70
|
+
raise ValueError("F must be finite and nonnegative.")
|
|
71
|
+
return self.curve.critical(min(value, self.plateau_f))
|
|
72
|
+
if np.any(~np.isfinite(values)) or np.any(values < 0):
|
|
73
|
+
raise ValueError("F must be finite and nonnegative.")
|
|
74
|
+
return self.curve.critical(np.minimum(values, self.plateau_f))
|
|
75
|
+
|
|
76
|
+
def confidence_interval(
|
|
77
|
+
self, coefficient: ArrayLike, se: ArrayLike, f_stat: ArrayLike
|
|
78
|
+
):
|
|
79
|
+
"""Construct intervals; broadcast inputs and put lower/upper bounds last."""
|
|
80
|
+
if any(np.iscomplexobj(value) for value in (coefficient, se, f_stat)):
|
|
81
|
+
raise ValueError("coefficient, se and F must be real.")
|
|
82
|
+
coefficient, se, f_stat = np.broadcast_arrays(coefficient, se, f_stat)
|
|
83
|
+
if (
|
|
84
|
+
np.any(~np.isfinite(coefficient))
|
|
85
|
+
or np.any(~np.isfinite(se))
|
|
86
|
+
or np.any(se <= 0)
|
|
87
|
+
):
|
|
88
|
+
raise ValueError("coefficient must be finite and se finite and positive.")
|
|
89
|
+
width = self.critical_value(f_stat) * se
|
|
90
|
+
return np.stack([coefficient - width, coefficient + width], axis=-1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def calibrate(
|
|
94
|
+
alpha: float = 0.05,
|
|
95
|
+
*,
|
|
96
|
+
epsilon: float = 1e-4,
|
|
97
|
+
seeds: int = 256,
|
|
98
|
+
size_tolerance: float = 2e-7,
|
|
99
|
+
strength_limit: float = 80.0,
|
|
100
|
+
correlations: ArrayLike | None = None,
|
|
101
|
+
strengths: ArrayLike | None = None,
|
|
102
|
+
optimize: bool = True,
|
|
103
|
+
) -> Calibration:
|
|
104
|
+
"""Construct and numerically calibrate a tF curve at a requested alpha.
|
|
105
|
+
|
|
106
|
+
The initial plateau satisfies Property (C) and is at least the normal
|
|
107
|
+
critical value. A grid and local searches check rejection probabilities;
|
|
108
|
+
violations move the plateau earlier on the decreasing branch. Diagnostics
|
|
109
|
+
report the finite search domain and numerical tolerances. Neither a finite
|
|
110
|
+
grid nor local optimization certifies the supremum over all nuisance values.
|
|
111
|
+
"""
|
|
112
|
+
if any(
|
|
113
|
+
value is not None and np.iscomplexobj(value)
|
|
114
|
+
for value in (size_tolerance, strength_limit, correlations, strengths)
|
|
115
|
+
):
|
|
116
|
+
raise ValueError("Calibration settings must be real.")
|
|
117
|
+
if not np.isfinite(size_tolerance) or not 1e-9 <= size_tolerance <= 1e-3:
|
|
118
|
+
raise ValueError("size_tolerance must be between 1e-9 and 1e-3.")
|
|
119
|
+
if not np.isfinite(strength_limit) or strength_limit < 20:
|
|
120
|
+
raise ValueError("strength_limit must be finite and at least 20.")
|
|
121
|
+
curve = construct_curve(alpha, epsilon=epsilon, seeds=seeds)
|
|
122
|
+
stop = curve.minimum_plateau_f
|
|
123
|
+
if correlations is None:
|
|
124
|
+
correlations = [0, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99, 0.995, 0.999, 0.9999, 1]
|
|
125
|
+
rho_grid = np.unique(np.r_[correlations, 0.0, 1.0]).astype(float)
|
|
126
|
+
if np.any(~np.isfinite(rho_grid)) or np.any((rho_grid < 0) | (rho_grid > 1)):
|
|
127
|
+
raise ValueError("correlations must be finite and lie in [0, 1].")
|
|
128
|
+
if strengths is None:
|
|
129
|
+
strengths = np.r_[0, 0.05, 0.1, 0.25, 0.5, np.geomspace(1, strength_limit, 32)]
|
|
130
|
+
m_grid = np.unique(np.r_[strengths, 0.0, strength_limit]).astype(float)
|
|
131
|
+
if np.any(~np.isfinite(m_grid)) or np.any((m_grid < 0) | (m_grid > strength_limit)):
|
|
132
|
+
raise ValueError("strengths must be finite and lie in [0, strength_limit].")
|
|
133
|
+
integration_tol = min(1e-9, size_tolerance / 50)
|
|
134
|
+
evaluations = 0
|
|
135
|
+
max_error = 0.0
|
|
136
|
+
|
|
137
|
+
def evaluate(m, rho, candidate):
|
|
138
|
+
nonlocal evaluations, max_error
|
|
139
|
+
result = rejection_probability(
|
|
140
|
+
curve, m, rho, plateau_f=candidate, atol=integration_tol
|
|
141
|
+
)
|
|
142
|
+
evaluations += 1
|
|
143
|
+
max_error = max(max_error, result.error)
|
|
144
|
+
if result.error > size_tolerance / 5:
|
|
145
|
+
raise ConvergenceError(
|
|
146
|
+
"Quadrature error is too large for size calibration."
|
|
147
|
+
)
|
|
148
|
+
return result.probability
|
|
149
|
+
|
|
150
|
+
def search(candidate):
|
|
151
|
+
records = [
|
|
152
|
+
(evaluate(m, rho, candidate), float(m), float(rho))
|
|
153
|
+
for m in m_grid
|
|
154
|
+
for rho in rho_grid
|
|
155
|
+
]
|
|
156
|
+
if optimize:
|
|
157
|
+
# Refine each resolved peak along the singular boundary, including
|
|
158
|
+
# the post-plateau hump which a coarse strength grid can miss.
|
|
159
|
+
singular = sorted((p, m) for p, m, r in records if r == 1)
|
|
160
|
+
for _, m in singular[-3:]:
|
|
161
|
+
lo, hi = max(0.001, m / 1.5), min(strength_limit, max(m + 1, m * 1.5))
|
|
162
|
+
result = cast(
|
|
163
|
+
"OptimizeResult",
|
|
164
|
+
minimize_scalar(
|
|
165
|
+
lambda x: -evaluate(x, 1.0, candidate),
|
|
166
|
+
bounds=(lo, hi),
|
|
167
|
+
method="bounded",
|
|
168
|
+
options={"xatol": 1e-5},
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
if result.success:
|
|
172
|
+
records.append((-float(result.fun), float(result.x), 1.0))
|
|
173
|
+
# Also search the interior; rho=1 is not assumed to be worst-case.
|
|
174
|
+
interior = sorted(record for record in records if record[2] < 0.999)
|
|
175
|
+
for _, m, rho in interior[-2:]:
|
|
176
|
+
result = minimize(
|
|
177
|
+
lambda x: -evaluate(float(x[0]), float(x[1]), candidate),
|
|
178
|
+
[m, rho],
|
|
179
|
+
method="Nelder-Mead",
|
|
180
|
+
bounds=[(0.0, strength_limit), (0.0, 0.99999)],
|
|
181
|
+
options={
|
|
182
|
+
"maxiter": 150,
|
|
183
|
+
"xatol": 1e-4,
|
|
184
|
+
"fatol": size_tolerance / 10,
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
if not result.success:
|
|
188
|
+
raise ConvergenceError("The interior size search did not converge.")
|
|
189
|
+
records.append(
|
|
190
|
+
(-float(result.fun), float(result.x[0]), float(result.x[1]))
|
|
191
|
+
)
|
|
192
|
+
return max(records)
|
|
193
|
+
|
|
194
|
+
for adjustments in range(12):
|
|
195
|
+
worst, m, rho = search(stop)
|
|
196
|
+
if worst <= curve.alpha + size_tolerance:
|
|
197
|
+
diagnostics = CalibrationDiagnostics(
|
|
198
|
+
curve.alpha,
|
|
199
|
+
stop,
|
|
200
|
+
float(curve.critical(stop)),
|
|
201
|
+
curve.property_c_endpoint,
|
|
202
|
+
worst,
|
|
203
|
+
m,
|
|
204
|
+
rho,
|
|
205
|
+
max_error,
|
|
206
|
+
size_tolerance,
|
|
207
|
+
strength_limit,
|
|
208
|
+
evaluations,
|
|
209
|
+
adjustments,
|
|
210
|
+
tuple(rho_grid),
|
|
211
|
+
tuple(m_grid),
|
|
212
|
+
)
|
|
213
|
+
return Calibration(curve, stop, diagnostics)
|
|
214
|
+
target = curve.alpha - integration_tol * 10
|
|
215
|
+
lower = curve.boundary + epsilon
|
|
216
|
+
if evaluate(m, rho, lower) > target:
|
|
217
|
+
raise ConvergenceError("No admissible plateau resolves the size violation.")
|
|
218
|
+
stop = cast(
|
|
219
|
+
"float",
|
|
220
|
+
brentq(
|
|
221
|
+
lambda value, m=m, rho=rho, target=target: (
|
|
222
|
+
evaluate(m, rho, value) - target
|
|
223
|
+
),
|
|
224
|
+
lower,
|
|
225
|
+
stop,
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
raise ConvergenceError("Plateau calibration exceeded 12 adjustments.")
|
tf_inference/cli.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Command-line evaluation and reproducible table generation."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from . import calibrate, critical_value, export_table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv=None):
|
|
12
|
+
"""Evaluate critical values or generate portable calibration files."""
|
|
13
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
14
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
15
|
+
evaluate = commands.add_parser("critical", help="Evaluate tF critical values")
|
|
16
|
+
evaluate.add_argument("F", nargs="+", type=float)
|
|
17
|
+
evaluate.add_argument("--alpha", type=float, default=0.05)
|
|
18
|
+
evaluate.add_argument("--method", choices=["table", "compute"], default="table")
|
|
19
|
+
generate = commands.add_parser(
|
|
20
|
+
"generate", help="Construct and export numerical tables"
|
|
21
|
+
)
|
|
22
|
+
generate.add_argument("--alpha", nargs="+", type=float, required=True)
|
|
23
|
+
generate.add_argument("--output", required=True)
|
|
24
|
+
generate.add_argument("--points", type=int, default=16385)
|
|
25
|
+
generate.add_argument("--csv", action="store_true", help="Also export gzip CSV")
|
|
26
|
+
generate.add_argument("--epsilon", type=float, default=1e-4)
|
|
27
|
+
generate.add_argument("--seeds", type=int, default=256)
|
|
28
|
+
generate.add_argument("--size-tolerance", type=float, default=2e-7)
|
|
29
|
+
generate.add_argument("--strength-limit", type=float, default=80)
|
|
30
|
+
args = parser.parse_args(argv)
|
|
31
|
+
if args.command == "critical":
|
|
32
|
+
try:
|
|
33
|
+
values = np.atleast_1d(
|
|
34
|
+
critical_value(args.F, args.alpha, method=args.method)
|
|
35
|
+
)
|
|
36
|
+
except ValueError as error:
|
|
37
|
+
parser.error(str(error))
|
|
38
|
+
print("F,critical_value")
|
|
39
|
+
for f_stat, value in zip(args.F, values, strict=True):
|
|
40
|
+
print(f"{f_stat:.17g},{value:.17g}")
|
|
41
|
+
else:
|
|
42
|
+
for alpha in args.alpha:
|
|
43
|
+
calibration = calibrate(
|
|
44
|
+
alpha,
|
|
45
|
+
epsilon=args.epsilon,
|
|
46
|
+
seeds=args.seeds,
|
|
47
|
+
size_tolerance=args.size_tolerance,
|
|
48
|
+
strength_limit=args.strength_limit,
|
|
49
|
+
)
|
|
50
|
+
metadata = export_table(
|
|
51
|
+
calibration, args.output, points=args.points, include_csv=args.csv
|
|
52
|
+
)
|
|
53
|
+
print(json.dumps(metadata), flush=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
tf_inference/curve.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""The Appendix B.3 inverse map and the Property (C) endpoint."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from math import isfinite
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import ArrayLike
|
|
9
|
+
from scipy.interpolate import PchipInterpolator
|
|
10
|
+
from scipy.optimize import OptimizeResult, brentq, minimize_scalar
|
|
11
|
+
from scipy.special import ndtr, ndtri
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConvergenceError(RuntimeError):
|
|
15
|
+
"""A numerical construction or calibration did not converge."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_alpha(alpha: float) -> float:
|
|
19
|
+
"""Validate the numerical implementation's significance-level domain."""
|
|
20
|
+
if np.iscomplexobj(alpha):
|
|
21
|
+
raise ValueError("alpha must be real.")
|
|
22
|
+
alpha = float(alpha)
|
|
23
|
+
if not np.isfinite(alpha) or not 0.0001 <= alpha <= 0.25:
|
|
24
|
+
raise ValueError("alpha must be finite and between 0.0001 and 0.25.")
|
|
25
|
+
return alpha
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def inverse_map(f_stat: ArrayLike, critical: ArrayLike, alpha: float):
|
|
29
|
+
"""Extend the decreasing branch using Appendix B.3, step 2."""
|
|
30
|
+
f_stat, critical = np.broadcast_arrays(f_stat, critical)
|
|
31
|
+
strength = f_stat / (critical - np.sqrt(f_stat))
|
|
32
|
+
# Survival probabilities avoid subtracting a small tail from one.
|
|
33
|
+
normal_quantile = -ndtri(alpha - ndtr(-np.sqrt(f_stat) - strength))
|
|
34
|
+
root_f = strength + normal_quantile
|
|
35
|
+
return root_f**2, root_f * normal_quantile / strength
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Curve:
|
|
40
|
+
"""A numerically constructed decreasing branch, before plateau calibration.
|
|
41
|
+
|
|
42
|
+
``critical`` returns the threshold for absolute t, not for t squared.
|
|
43
|
+
The interpolated quantity is (F - boundary) * critical(F)**2.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
alpha: float
|
|
47
|
+
boundary: float
|
|
48
|
+
f: np.ndarray
|
|
49
|
+
regularized: np.ndarray
|
|
50
|
+
epsilon: float
|
|
51
|
+
seeds: int
|
|
52
|
+
property_c_endpoint: float
|
|
53
|
+
minimum_plateau_f: float
|
|
54
|
+
_spline: PchipInterpolator
|
|
55
|
+
|
|
56
|
+
def regularized_squared(self, f_stat: ArrayLike):
|
|
57
|
+
"""Evaluate the finite regularized squared critical value.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
f_stat: Real F values between the boundary and the branch endpoint.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Values of (F - boundary) times the squared critical value.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If F is non-real, nonfinite, or outside the branch.
|
|
67
|
+
"""
|
|
68
|
+
if np.iscomplexobj(f_stat):
|
|
69
|
+
raise ValueError("F must be real.")
|
|
70
|
+
values = np.asarray(f_stat, dtype=float)
|
|
71
|
+
if (
|
|
72
|
+
np.any(~np.isfinite(values))
|
|
73
|
+
or np.any(values < self.boundary)
|
|
74
|
+
or np.any(values > self.f[-1])
|
|
75
|
+
):
|
|
76
|
+
raise ValueError("F lies outside the constructed decreasing branch.")
|
|
77
|
+
return self._spline(values)
|
|
78
|
+
|
|
79
|
+
def critical(self, f_stat: ArrayLike):
|
|
80
|
+
"""Evaluate the decreasing branch within its constructed domain."""
|
|
81
|
+
if np.iscomplexobj(f_stat):
|
|
82
|
+
raise ValueError("F must be real.")
|
|
83
|
+
values = np.asarray(f_stat, dtype=float)
|
|
84
|
+
if values.ndim == 0:
|
|
85
|
+
value = float(values)
|
|
86
|
+
if not isfinite(value) or value < 0:
|
|
87
|
+
raise ValueError("F must be finite and nonnegative.")
|
|
88
|
+
if value > self.f[-1]:
|
|
89
|
+
raise ValueError("F lies outside the constructed decreasing branch.")
|
|
90
|
+
if value <= self.boundary:
|
|
91
|
+
return float("inf")
|
|
92
|
+
return float(np.sqrt(self._spline(value) / (value - self.boundary)))
|
|
93
|
+
if np.any(~np.isfinite(values)) or np.any(values < 0):
|
|
94
|
+
raise ValueError("F must be finite and nonnegative.")
|
|
95
|
+
if np.any(values > self.f[-1]):
|
|
96
|
+
raise ValueError("F lies outside the constructed decreasing branch.")
|
|
97
|
+
result = np.full(values.shape, np.inf)
|
|
98
|
+
finite = values > self.boundary
|
|
99
|
+
result[finite] = np.sqrt(
|
|
100
|
+
self._spline(values[finite]) / (values[finite] - self.boundary)
|
|
101
|
+
)
|
|
102
|
+
return float(result) if result.ndim == 0 else result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def construct_curve(
|
|
106
|
+
alpha: float = 0.05, *, epsilon: float = 1e-4, seeds: int = 256
|
|
107
|
+
) -> Curve:
|
|
108
|
+
"""Construct Appendix B.3's decreasing branch and its admissible endpoint.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
alpha: Two-sided significance level, between 0.0001 and 0.25.
|
|
112
|
+
epsilon: Initialization distance from the chi-square boundary.
|
|
113
|
+
seeds: Number of starting points across one inverse-map interval.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Candidate decreasing curve and its admissible endpoint. Use ``calibrate``
|
|
117
|
+
to check a plateau before using the curve for inference.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ValueError: If the significance level or construction settings are invalid.
|
|
121
|
+
ConvergenceError: If the recurrence or tangency calculation fails.
|
|
122
|
+
"""
|
|
123
|
+
alpha = validate_alpha(alpha)
|
|
124
|
+
if np.iscomplexobj(epsilon):
|
|
125
|
+
raise ValueError("epsilon must be real.")
|
|
126
|
+
if not np.isfinite(epsilon) or not 1e-6 <= epsilon <= 1e-3:
|
|
127
|
+
raise ValueError("epsilon must be between 1e-6 and 1e-3.")
|
|
128
|
+
if isinstance(seeds, bool) or not isinstance(seeds, int) or seeds < 32:
|
|
129
|
+
raise ValueError("seeds must be an integer of at least 32.")
|
|
130
|
+
q = float(ndtri(alpha / 2) ** 2)
|
|
131
|
+
b = 3 * q - q**2 / 2 + q**3 / 6
|
|
132
|
+
start = q + epsilon
|
|
133
|
+
end = float(inverse_map(start, np.sqrt(q**3 / (start - q) - b), alpha)[0])
|
|
134
|
+
f = np.linspace(start, end, seeds, endpoint=False)
|
|
135
|
+
critical = np.sqrt(q**3 / (f - q) - b)
|
|
136
|
+
f_parts, c_parts = [], []
|
|
137
|
+
for _ in range(10000):
|
|
138
|
+
f_parts.append(f)
|
|
139
|
+
c_parts.append(critical)
|
|
140
|
+
valid = (critical > np.sqrt(f)) & (f < 1e5)
|
|
141
|
+
if not np.any(valid):
|
|
142
|
+
break
|
|
143
|
+
next_f, next_c = inverse_map(f[valid], critical[valid], alpha)
|
|
144
|
+
if np.any(~np.isfinite(next_f)) or np.any(next_f <= f[valid]):
|
|
145
|
+
raise ConvergenceError("The inverse map failed to extend its domain.")
|
|
146
|
+
f, critical = next_f, next_c
|
|
147
|
+
else:
|
|
148
|
+
raise ConvergenceError("The inverse map exceeded 10000 iterations.")
|
|
149
|
+
f, critical = np.concatenate(f_parts), np.concatenate(c_parts)
|
|
150
|
+
order = np.argsort(f)
|
|
151
|
+
f, critical = f[order], critical[order]
|
|
152
|
+
unique = np.r_[True, np.diff(f) > 0]
|
|
153
|
+
f, critical = f[unique], critical[unique]
|
|
154
|
+
if np.any(np.diff(critical) > 1e-8 * critical[:-1]):
|
|
155
|
+
raise ConvergenceError(
|
|
156
|
+
"The constructed critical-value branch is not decreasing."
|
|
157
|
+
)
|
|
158
|
+
regularized = np.r_[q**3, (f - q) * critical**2]
|
|
159
|
+
f = np.r_[q, f]
|
|
160
|
+
spline = PchipInterpolator(f, regularized, extrapolate=False)
|
|
161
|
+
|
|
162
|
+
def cv(value):
|
|
163
|
+
return np.sqrt(spline(value) / (value - q))
|
|
164
|
+
|
|
165
|
+
# An interior rejection interval appears when strength exceeds min_F
|
|
166
|
+
# F / (sqrt(F) - critical(F)). This is the tangency in Property (C).
|
|
167
|
+
lower = f[1:][f[1:] > critical**2][0]
|
|
168
|
+
upper = min(1e4, f[-1])
|
|
169
|
+
contact = cast(
|
|
170
|
+
"OptimizeResult",
|
|
171
|
+
minimize_scalar(
|
|
172
|
+
lambda value: value / (np.sqrt(value) - cv(value)),
|
|
173
|
+
bounds=(lower, upper),
|
|
174
|
+
method="bounded",
|
|
175
|
+
options={"xatol": 1e-9},
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
if not contact.success:
|
|
179
|
+
raise ConvergenceError("Could not locate the Property (C) tangency.")
|
|
180
|
+
endpoint = cast(
|
|
181
|
+
"float",
|
|
182
|
+
brentq(
|
|
183
|
+
lambda value: value / (np.sqrt(value) + cv(value)) - contact.fun,
|
|
184
|
+
contact.x,
|
|
185
|
+
upper,
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
plateau_f = endpoint
|
|
189
|
+
if cv(endpoint) < np.sqrt(q):
|
|
190
|
+
plateau_f = cast(
|
|
191
|
+
"float", brentq(lambda value: cv(value) - np.sqrt(q), q + epsilon, endpoint)
|
|
192
|
+
)
|
|
193
|
+
f.setflags(write=False)
|
|
194
|
+
regularized.setflags(write=False)
|
|
195
|
+
return Curve(alpha, q, f, regularized, epsilon, seeds, endpoint, plateau_f, spline)
|
|
Binary file
|