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.
@@ -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)
@@ -0,0 +1,4 @@
1
+ {
2
+ "schema_version": 2,
3
+ "parquet_sha256": "10a3d5ede0ab6e75c0559bb1e4bc8f1df9033ba949f6b0f53518185864d39933"
4
+ }
Binary file