westata 0.0.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.
- westata/__init__.py +223 -0
- westata/conformance/__init__.py +37 -0
- westata/conformance/oracles/__init__.py +7 -0
- westata/conformance/oracles/invariants.py +124 -0
- westata/conformance/oracles/stata.py +270 -0
- westata/conformance/registry.py +180 -0
- westata/conformance/tolerances.py +91 -0
- westata/data/__init__.py +7 -0
- westata/data/weights.py +134 -0
- westata/errors.py +45 -0
- westata/estimators/__init__.py +164 -0
- westata/estimators/counts.py +1040 -0
- westata/estimators/did.py +840 -0
- westata/estimators/discrete.py +1616 -0
- westata/estimators/hdfe.py +677 -0
- westata/estimators/iv.py +1037 -0
- westata/estimators/limited.py +2323 -0
- westata/estimators/linear.py +349 -0
- westata/estimators/nonlinear.py +1683 -0
- westata/estimators/panel.py +1111 -0
- westata/estimators/panel_dynamic.py +496 -0
- westata/estimators/panel_iv.py +2224 -0
- westata/estimators/panel_nonlinear.py +2073 -0
- westata/estimators/quantile.py +1075 -0
- westata/estimators/quantile_boot.py +348 -0
- westata/estimators/resample.py +698 -0
- westata/estimators/survey.py +1296 -0
- westata/estimators/systems.py +1208 -0
- westata/estimators/tobit.py +611 -0
- westata/estimators/treatreg_effects.py +1304 -0
- westata/estimators/tseries.py +1607 -0
- westata/estimators/xtunitroot_ips.py +290 -0
- westata/estimators/xtunitroot_llc.py +405 -0
- westata/expressions/__init__.py +21 -0
- westata/expressions/explist.py +536 -0
- westata/formula/__init__.py +11 -0
- westata/formula/factor.py +114 -0
- westata/postestimation/__init__.py +56 -0
- westata/postestimation/api.py +447 -0
- westata/postestimation/contrast.py +582 -0
- westata/postestimation/lincom.py +133 -0
- westata/postestimation/margins.py +805 -0
- westata/postestimation/modeltests.py +431 -0
- westata/postestimation/nonlinear.py +746 -0
- westata/postestimation/predict.py +823 -0
- westata/postestimation/tests.py +210 -0
- westata/protocols.py +56 -0
- westata/random/__init__.py +9 -0
- westata/random/stata_rng.py +95 -0
- westata/report/__init__.py +7 -0
- westata/report/etable.py +219 -0
- westata/results/__init__.py +14 -0
- westata/results/base.py +464 -0
- westata/results/stata_return.py +67 -0
- westata/results/summary.py +323 -0
- westata/vce/__init__.py +36 -0
- westata/vce/cluster.py +100 -0
- westata/vce/dispatch.py +199 -0
- westata/vce/robust.py +131 -0
- westata/vce/ssc.py +75 -0
- westata-0.0.1.dist-info/METADATA +354 -0
- westata-0.0.1.dist-info/RECORD +65 -0
- westata-0.0.1.dist-info/WHEEL +5 -0
- westata-0.0.1.dist-info/licenses/LICENSE +21 -0
- westata-0.0.1.dist-info/top_level.txt +1 -0
westata/__init__.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Export postestimation and report helpers from the top-level package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from westata.data import WEIGHT_TYPES, WeightSpec, normalize_weights
|
|
6
|
+
from westata.expressions.explist import (
|
|
7
|
+
ExpListError,
|
|
8
|
+
ParsedExpressionList,
|
|
9
|
+
StoredResults,
|
|
10
|
+
parse_exp_list,
|
|
11
|
+
)
|
|
12
|
+
from westata.estimators import (
|
|
13
|
+
LlcResult,
|
|
14
|
+
levin_lin_chu,
|
|
15
|
+
IpsResult,
|
|
16
|
+
im_pesaran_shin,
|
|
17
|
+
Bootstrap,
|
|
18
|
+
Jackknife,
|
|
19
|
+
Permute,
|
|
20
|
+
SurveyDesign,
|
|
21
|
+
SvyLogit,
|
|
22
|
+
SvyMean,
|
|
23
|
+
SvyProportion,
|
|
24
|
+
SvyRegress,
|
|
25
|
+
SvyTotal,
|
|
26
|
+
AIPW,
|
|
27
|
+
Didregress,
|
|
28
|
+
IPW,
|
|
29
|
+
NearestNeighbourMatching,
|
|
30
|
+
RegressionAdjustment,
|
|
31
|
+
TreatmentEffectResult,
|
|
32
|
+
Xtdidregress,
|
|
33
|
+
Frontier,
|
|
34
|
+
Nl,
|
|
35
|
+
Eivreg,
|
|
36
|
+
Manova,
|
|
37
|
+
ManovaResult,
|
|
38
|
+
Mvreg,
|
|
39
|
+
Sureg,
|
|
40
|
+
XtAbond,
|
|
41
|
+
XtLogit,
|
|
42
|
+
XtNbreg,
|
|
43
|
+
XtPoisson,
|
|
44
|
+
XtProbit,
|
|
45
|
+
XtTobit,
|
|
46
|
+
XtGls,
|
|
47
|
+
XtIvreg,
|
|
48
|
+
XtPcse,
|
|
49
|
+
XtUnitroot,
|
|
50
|
+
Bsqreg,
|
|
51
|
+
Sqreg,
|
|
52
|
+
QuantileRegression,
|
|
53
|
+
RobustRegression,
|
|
54
|
+
Etregress,
|
|
55
|
+
Heckman,
|
|
56
|
+
Intreg,
|
|
57
|
+
Truncreg,
|
|
58
|
+
TruncatedNegativeBinomial,
|
|
59
|
+
TruncatedPoisson,
|
|
60
|
+
ZeroInflatedNegativeBinomial,
|
|
61
|
+
ZeroInflatedPoisson,
|
|
62
|
+
ARIMA,
|
|
63
|
+
FEOLS,
|
|
64
|
+
GARCH,
|
|
65
|
+
IV2SLS,
|
|
66
|
+
IVGMM,
|
|
67
|
+
IVLIML,
|
|
68
|
+
OLS,
|
|
69
|
+
VAR,
|
|
70
|
+
VECM,
|
|
71
|
+
BetweenOLS,
|
|
72
|
+
FirstDifferenceOLS,
|
|
73
|
+
Logit,
|
|
74
|
+
NegativeBinomial,
|
|
75
|
+
NegativeBinomialNB1,
|
|
76
|
+
OrderedLogit,
|
|
77
|
+
OrderedProbit,
|
|
78
|
+
PanelOLS,
|
|
79
|
+
Poisson,
|
|
80
|
+
Probit,
|
|
81
|
+
RandomEffects,
|
|
82
|
+
Tobit,
|
|
83
|
+
UnitRootResult,
|
|
84
|
+
unitroot,
|
|
85
|
+
)
|
|
86
|
+
from westata.errors import (
|
|
87
|
+
ConsistencyError,
|
|
88
|
+
ConvergenceError,
|
|
89
|
+
FormulaError,
|
|
90
|
+
UnsupportedError,
|
|
91
|
+
WeightError,
|
|
92
|
+
WestataError,
|
|
93
|
+
)
|
|
94
|
+
from westata.postestimation import (
|
|
95
|
+
HausmanResult,
|
|
96
|
+
LincomResult,
|
|
97
|
+
LRTestResult,
|
|
98
|
+
MarginsResult,
|
|
99
|
+
WaldTest,
|
|
100
|
+
contrast,
|
|
101
|
+
hausman,
|
|
102
|
+
lrtest,
|
|
103
|
+
margins,
|
|
104
|
+
nlcom,
|
|
105
|
+
pwcompare,
|
|
106
|
+
testnl,
|
|
107
|
+
)
|
|
108
|
+
from westata.protocols import SSC
|
|
109
|
+
from westata.report import etable, to_latex, to_markdown
|
|
110
|
+
|
|
111
|
+
__version__ = "0.0.1"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ssc(**kwargs: object) -> SSC:
|
|
115
|
+
"""Convenience constructor mirroring ``ws.ssc(adj=True, ...)``."""
|
|
116
|
+
|
|
117
|
+
return SSC(**kwargs) # type: ignore[arg-type]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"ParsedExpressionList",
|
|
122
|
+
"StoredResults",
|
|
123
|
+
"ExpListError",
|
|
124
|
+
"parse_exp_list",
|
|
125
|
+
"LlcResult",
|
|
126
|
+
"levin_lin_chu",
|
|
127
|
+
"IpsResult",
|
|
128
|
+
"im_pesaran_shin",
|
|
129
|
+
"Permute",
|
|
130
|
+
"Bootstrap",
|
|
131
|
+
"Jackknife",
|
|
132
|
+
"SvyLogit",
|
|
133
|
+
"SvyRegress",
|
|
134
|
+
"SvyProportion",
|
|
135
|
+
"SvyTotal",
|
|
136
|
+
"SvyMean",
|
|
137
|
+
"SurveyDesign",
|
|
138
|
+
"TreatmentEffectResult",
|
|
139
|
+
"NearestNeighbourMatching",
|
|
140
|
+
"AIPW",
|
|
141
|
+
"IPW",
|
|
142
|
+
"RegressionAdjustment",
|
|
143
|
+
"Xtdidregress",
|
|
144
|
+
"Didregress",
|
|
145
|
+
"Frontier",
|
|
146
|
+
"Nl",
|
|
147
|
+
"ManovaResult",
|
|
148
|
+
"Manova",
|
|
149
|
+
"Eivreg",
|
|
150
|
+
"Mvreg",
|
|
151
|
+
"Sureg",
|
|
152
|
+
"XtAbond",
|
|
153
|
+
"XtNbreg",
|
|
154
|
+
"XtPoisson",
|
|
155
|
+
"XtTobit",
|
|
156
|
+
"XtProbit",
|
|
157
|
+
"XtLogit",
|
|
158
|
+
"XtUnitroot",
|
|
159
|
+
"XtPcse",
|
|
160
|
+
"XtGls",
|
|
161
|
+
"XtIvreg",
|
|
162
|
+
"Bsqreg",
|
|
163
|
+
"Sqreg",
|
|
164
|
+
"RobustRegression",
|
|
165
|
+
"QuantileRegression",
|
|
166
|
+
"TruncatedNegativeBinomial",
|
|
167
|
+
"TruncatedPoisson",
|
|
168
|
+
"ZeroInflatedNegativeBinomial",
|
|
169
|
+
"ZeroInflatedPoisson",
|
|
170
|
+
"Etregress",
|
|
171
|
+
"Heckman",
|
|
172
|
+
"Intreg",
|
|
173
|
+
"Truncreg",
|
|
174
|
+
"HausmanResult",
|
|
175
|
+
"LRTestResult",
|
|
176
|
+
"pwcompare",
|
|
177
|
+
"contrast",
|
|
178
|
+
"hausman",
|
|
179
|
+
"lrtest",
|
|
180
|
+
"testnl",
|
|
181
|
+
"nlcom",
|
|
182
|
+
"SSC",
|
|
183
|
+
"ssc",
|
|
184
|
+
"OLS",
|
|
185
|
+
"IV2SLS",
|
|
186
|
+
"IVLIML",
|
|
187
|
+
"IVGMM",
|
|
188
|
+
"FEOLS",
|
|
189
|
+
"PanelOLS",
|
|
190
|
+
"RandomEffects",
|
|
191
|
+
"BetweenOLS",
|
|
192
|
+
"FirstDifferenceOLS",
|
|
193
|
+
"Logit",
|
|
194
|
+
"Probit",
|
|
195
|
+
"Poisson",
|
|
196
|
+
"NegativeBinomial",
|
|
197
|
+
"OrderedLogit",
|
|
198
|
+
"OrderedProbit",
|
|
199
|
+
"Tobit",
|
|
200
|
+
"ARIMA",
|
|
201
|
+
"VAR",
|
|
202
|
+
"VECM",
|
|
203
|
+
"GARCH",
|
|
204
|
+
"UnitRootResult",
|
|
205
|
+
"unitroot",
|
|
206
|
+
"WaldTest",
|
|
207
|
+
"LincomResult",
|
|
208
|
+
"MarginsResult",
|
|
209
|
+
"margins",
|
|
210
|
+
"etable",
|
|
211
|
+
"to_latex",
|
|
212
|
+
"to_markdown",
|
|
213
|
+
"WEIGHT_TYPES",
|
|
214
|
+
"WeightSpec",
|
|
215
|
+
"normalize_weights",
|
|
216
|
+
"WestataError",
|
|
217
|
+
"ConsistencyError",
|
|
218
|
+
"ConvergenceError",
|
|
219
|
+
"FormulaError",
|
|
220
|
+
"UnsupportedError",
|
|
221
|
+
"WeightError",
|
|
222
|
+
"__version__",
|
|
223
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Conformance harness: evidence registry, tolerances and oracle adapters.
|
|
2
|
+
|
|
3
|
+
Design spec: ``docs/superpowers/specs/2026-10-22-westata-design.md`` section
|
|
4
|
+
11. Numeric claims against Stata are only as strong as their evidence level,
|
|
5
|
+
so the suite keeps the claim (registry), the yardstick (tolerances) and the
|
|
6
|
+
checkable identities (oracles) together.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from westata.conformance.oracles.invariants import check_ols_invariants
|
|
12
|
+
from westata.conformance.registry import (
|
|
13
|
+
VALID_LEVELS,
|
|
14
|
+
VALID_SOURCE_TYPES,
|
|
15
|
+
Evidence,
|
|
16
|
+
load_registry,
|
|
17
|
+
validate_registry,
|
|
18
|
+
)
|
|
19
|
+
from westata.conformance.tolerances import (
|
|
20
|
+
LINEAR_RTOL,
|
|
21
|
+
MLE_RTOL,
|
|
22
|
+
MLE_SE_RTOL,
|
|
23
|
+
assert_close,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"LINEAR_RTOL",
|
|
28
|
+
"MLE_RTOL",
|
|
29
|
+
"MLE_SE_RTOL",
|
|
30
|
+
"VALID_LEVELS",
|
|
31
|
+
"VALID_SOURCE_TYPES",
|
|
32
|
+
"Evidence",
|
|
33
|
+
"assert_close",
|
|
34
|
+
"check_ols_invariants",
|
|
35
|
+
"load_registry",
|
|
36
|
+
"validate_registry",
|
|
37
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Invariant oracles: identities every correct OLS fit must satisfy.
|
|
2
|
+
|
|
3
|
+
Design spec: ``docs/superpowers/specs/2026-10-22-westata-design.md`` section
|
|
4
|
+
11.2 (``InvariantOracle``). Least squares is defined by the normal equations,
|
|
5
|
+
so any estimator (our own, statsmodels, Stata) must produce residuals
|
|
6
|
+
orthogonal to the design matrix, and — when a constant is included — residuals
|
|
7
|
+
that sum to zero. These checks need no Stata license and no fixture.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _as_float_array(value: object, name: str) -> np.ndarray:
|
|
18
|
+
"""Coerce ``value`` to a float array, raising ``TypeError`` on garbage."""
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
return np.asarray(value, dtype=float)
|
|
22
|
+
except (TypeError, ValueError) as exc:
|
|
23
|
+
raise TypeError(
|
|
24
|
+
f"{name} must be numeric, got {type(value).__name__}: {value!r}"
|
|
25
|
+
) from exc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_ols_invariants(
|
|
29
|
+
X: np.ndarray,
|
|
30
|
+
resid: np.ndarray,
|
|
31
|
+
params: np.ndarray,
|
|
32
|
+
*,
|
|
33
|
+
has_intercept: bool,
|
|
34
|
+
atol: float = 1e-10,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Assert the least-squares normal equations for a fitted model.
|
|
37
|
+
|
|
38
|
+
Checks
|
|
39
|
+
------
|
|
40
|
+
1. ``X.T @ resid ~= 0`` (residuals orthogonal to every regressor).
|
|
41
|
+
2. When ``has_intercept`` is true, ``sum(resid) ~= 0``.
|
|
42
|
+
|
|
43
|
+
Floating-point orthogonality is judged against the problem scale:
|
|
44
|
+
``max|X.T @ resid|`` is compared with ``atol * ||X||_inf * ||resid||_inf *
|
|
45
|
+
nobs``. A raw absolute threshold would reject exact solves on
|
|
46
|
+
large-magnitude regressors (e.g. wages measured in thousands), because
|
|
47
|
+
cancellation alone leaves a residual gradient of order
|
|
48
|
+
``eps * ||X|| * ||resid|| * nobs``.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
X:
|
|
53
|
+
``nobs x nparams`` design matrix.
|
|
54
|
+
resid:
|
|
55
|
+
``nobs`` residual vector of the fitted model.
|
|
56
|
+
params:
|
|
57
|
+
Fitted coefficient vector, shape ``(nparams,)``. It is not used in the
|
|
58
|
+
identity itself, but requiring it keeps callers from pairing residuals
|
|
59
|
+
with the wrong design matrix.
|
|
60
|
+
has_intercept:
|
|
61
|
+
Whether the model includes a constant term; only then is
|
|
62
|
+
``sum(resid) = 0`` expected.
|
|
63
|
+
atol:
|
|
64
|
+
Scale-relative tolerance, positive and finite.
|
|
65
|
+
|
|
66
|
+
Raises
|
|
67
|
+
------
|
|
68
|
+
ValueError
|
|
69
|
+
On malformed inputs (wrong shapes, non-finite data, bad ``atol``).
|
|
70
|
+
AssertionError
|
|
71
|
+
When the normal equations or the intercept condition do not hold.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
X_arr = _as_float_array(X, "X")
|
|
75
|
+
resid_arr = _as_float_array(resid, "resid")
|
|
76
|
+
params_arr = _as_float_array(params, "params")
|
|
77
|
+
|
|
78
|
+
if X_arr.ndim != 2 or X_arr.shape[0] == 0 or X_arr.shape[1] == 0:
|
|
79
|
+
raise ValueError(f"X must be a non-empty 2-D array, got shape {X_arr.shape}")
|
|
80
|
+
nobs, nparams = X_arr.shape
|
|
81
|
+
if params_arr.shape != (nparams,):
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"params must have shape ({nparams},) to match X, got {params_arr.shape}"
|
|
84
|
+
)
|
|
85
|
+
if resid_arr.shape != (nobs,):
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"resid must have shape ({nobs},) to match X, got {resid_arr.shape}"
|
|
88
|
+
)
|
|
89
|
+
if not math.isfinite(atol) or atol <= 0:
|
|
90
|
+
raise ValueError(f"atol must be positive and finite, got {atol!r}")
|
|
91
|
+
if not (np.all(np.isfinite(X_arr)) and np.all(np.isfinite(resid_arr))):
|
|
92
|
+
raise ValueError("X and resid must contain only finite values")
|
|
93
|
+
|
|
94
|
+
# 1) Normal equations: X.T @ resid == 0.
|
|
95
|
+
gradient = X_arr.T @ resid_arr
|
|
96
|
+
gradient_scale = max(
|
|
97
|
+
1.0,
|
|
98
|
+
float(np.max(np.abs(X_arr))) * float(np.max(np.abs(resid_arr))) * nobs,
|
|
99
|
+
)
|
|
100
|
+
gradient_threshold = atol * gradient_scale
|
|
101
|
+
max_gradient = float(np.max(np.abs(gradient)))
|
|
102
|
+
if max_gradient > gradient_threshold:
|
|
103
|
+
raise AssertionError(
|
|
104
|
+
"OLS normal equations violated: max |X.T @ resid| = "
|
|
105
|
+
f"{max_gradient:.6e} exceeds scale-adjusted threshold "
|
|
106
|
+
f"{gradient_threshold:.6e} "
|
|
107
|
+
f"(atol={atol:.3g}, scale={gradient_scale:.6e})"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# 2) Intercept condition: sum(resid) == 0.
|
|
111
|
+
if has_intercept:
|
|
112
|
+
total_resid = abs(float(np.sum(resid_arr)))
|
|
113
|
+
sum_scale = max(1.0, nobs * float(np.max(np.abs(resid_arr))))
|
|
114
|
+
sum_threshold = atol * sum_scale
|
|
115
|
+
if total_resid > sum_threshold:
|
|
116
|
+
raise AssertionError(
|
|
117
|
+
"OLS intercept invariant violated: |sum(resid)| = "
|
|
118
|
+
f"{total_resid:.6e} exceeds scale-adjusted threshold "
|
|
119
|
+
f"{sum_threshold:.6e} "
|
|
120
|
+
f"(atol={atol:.3g}, scale={sum_scale:.6e})"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = ["check_ols_invariants"]
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Stata oracle: run real Stata and capture ``e()`` for conformance checks.
|
|
2
|
+
|
|
3
|
+
Requires StataNow/Stata 17+ with the bundled ``pystata`` package and a valid
|
|
4
|
+
licence. This module is a *test* dependency; nothing in ``westata`` imports it
|
|
5
|
+
at runtime.
|
|
6
|
+
|
|
7
|
+
Two properties matter for reproducible evidence:
|
|
8
|
+
|
|
9
|
+
* Results are captured as plain floats/arrays and can be written to JSON, so a
|
|
10
|
+
comparison can be replayed on a machine without Stata.
|
|
11
|
+
* Every capture records ``c(stata_version)`` and ``c(version)``, because Stata
|
|
12
|
+
results are only meaningful relative to a version.
|
|
13
|
+
|
|
14
|
+
Typical use::
|
|
15
|
+
|
|
16
|
+
with StataOracle(stata_dir=r"D:\\Program Files\\StataNow19", edition="mp") as so:
|
|
17
|
+
so.run("sysuse auto, clear")
|
|
18
|
+
so.run("regress mpg weight foreign")
|
|
19
|
+
capture = so.capture()
|
|
20
|
+
capture["b"] # coefficients, in e(b) order
|
|
21
|
+
capture["V"] # covariance matrix
|
|
22
|
+
capture["scalars"] # e(N), e(df_r), e(r2), ...
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Iterable, Sequence
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
__all__ = ["StataCapture", "StataOracle", "stata_available"]
|
|
37
|
+
|
|
38
|
+
#: Default install location on this machine; override via ``stata_dir``.
|
|
39
|
+
DEFAULT_STATA_DIR = r"D:\Program Files\StataNow19"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def stata_available() -> bool:
|
|
43
|
+
"""Whether a Stata installation with ``pystata`` is reachable."""
|
|
44
|
+
|
|
45
|
+
candidate = Path(DEFAULT_STATA_DIR) / "utilities"
|
|
46
|
+
return candidate.is_dir()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _strip_prefix(key: str) -> str:
|
|
50
|
+
"""``"e(N)" -> "N"``; leave already-bare names alone."""
|
|
51
|
+
|
|
52
|
+
if key.startswith("e(") and key.endswith(")"):
|
|
53
|
+
return key[2:-1]
|
|
54
|
+
return key
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class StataCapture:
|
|
59
|
+
"""Everything a conformance check needs from one Stata estimation.
|
|
60
|
+
|
|
61
|
+
Attributes
|
|
62
|
+
----------
|
|
63
|
+
b:
|
|
64
|
+
Coefficient vector in ``e(b)`` order.
|
|
65
|
+
V:
|
|
66
|
+
Covariance matrix of the estimators.
|
|
67
|
+
scalars:
|
|
68
|
+
Scalar ``e()`` results keyed without the ``e()`` wrapper.
|
|
69
|
+
macros:
|
|
70
|
+
String-valued ``e()`` results (``cmd``, ``vce``, ...).
|
|
71
|
+
names:
|
|
72
|
+
Coefficient names in ``e(b)`` order.
|
|
73
|
+
version:
|
|
74
|
+
Stata version string the capture was produced with.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
b: np.ndarray
|
|
78
|
+
V: np.ndarray
|
|
79
|
+
scalars: dict[str, float]
|
|
80
|
+
macros: dict[str, str]
|
|
81
|
+
names: list[str] = field(default_factory=list)
|
|
82
|
+
version: str = ""
|
|
83
|
+
|
|
84
|
+
def __getitem__(self, key: str) -> Any:
|
|
85
|
+
if key in self.scalars:
|
|
86
|
+
return self.scalars[key]
|
|
87
|
+
raise KeyError(f"{key!r} not in captured e() scalars: {sorted(self.scalars)}")
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, Any]:
|
|
90
|
+
return {
|
|
91
|
+
"b": self.b.tolist(),
|
|
92
|
+
"V": self.V.tolist(),
|
|
93
|
+
"scalars": self.scalars,
|
|
94
|
+
"macros": self.macros,
|
|
95
|
+
"names": self.names,
|
|
96
|
+
"version": self.version,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def save(self, path: str | Path) -> Path:
|
|
100
|
+
path = Path(path)
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
path.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
|
|
103
|
+
return path
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def load(cls, path: str | Path) -> "StataCapture":
|
|
107
|
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
108
|
+
return cls(
|
|
109
|
+
b=np.asarray(payload["b"], dtype=float),
|
|
110
|
+
V=np.asarray(payload["V"], dtype=float),
|
|
111
|
+
scalars={k: float(v) for k, v in payload["scalars"].items()},
|
|
112
|
+
macros=payload["macros"],
|
|
113
|
+
names=list(payload.get("names", [])),
|
|
114
|
+
version=payload.get("version", ""),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class StataOracle:
|
|
119
|
+
"""Context manager that owns one embedded Stata session.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
stata_dir:
|
|
124
|
+
Stata installation directory (the one containing ``utilities/pystata``).
|
|
125
|
+
edition:
|
|
126
|
+
``"mp"``, ``"se"`` or ``"be"``.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
stata_dir: str = DEFAULT_STATA_DIR,
|
|
132
|
+
edition: str = "mp",
|
|
133
|
+
) -> None:
|
|
134
|
+
self._stata_dir = stata_dir
|
|
135
|
+
self._edition = edition
|
|
136
|
+
self._stata: Any = None
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------ lifecycle
|
|
139
|
+
def __enter__(self) -> "StataOracle":
|
|
140
|
+
self.start()
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
144
|
+
self.close()
|
|
145
|
+
|
|
146
|
+
def start(self) -> None:
|
|
147
|
+
utilities = str(Path(self._stata_dir) / "utilities")
|
|
148
|
+
if utilities not in sys.path:
|
|
149
|
+
sys.path.append(utilities)
|
|
150
|
+
from pystata import config
|
|
151
|
+
|
|
152
|
+
config.init(self._edition, splash=False)
|
|
153
|
+
from pystata import stata
|
|
154
|
+
|
|
155
|
+
self._stata = stata
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
# pystata has no explicit shutdown; dropping the reference is enough and
|
|
159
|
+
# avoids surprising the caller with a half-dead interpreter.
|
|
160
|
+
self._stata = None
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------- running
|
|
163
|
+
def run(self, command: str, *, quietly: bool = True) -> None:
|
|
164
|
+
if self._stata is None:
|
|
165
|
+
raise RuntimeError("StataOracle is not started; use it as a context manager")
|
|
166
|
+
self._stata.run(command, quietly=quietly)
|
|
167
|
+
|
|
168
|
+
def run_all(self, lines: Iterable[str]) -> None:
|
|
169
|
+
for line in lines:
|
|
170
|
+
self.run(line)
|
|
171
|
+
|
|
172
|
+
def send_data(self, frame: Any, name: str = "df") -> None:
|
|
173
|
+
"""Load a pandas DataFrame into Stata under ``name``."""
|
|
174
|
+
|
|
175
|
+
if self._stata is None:
|
|
176
|
+
raise RuntimeError("StataOracle is not started")
|
|
177
|
+
self._stata.pdataframe_to_data(frame, force=True)
|
|
178
|
+
|
|
179
|
+
def get_output(self, command: str) -> str:
|
|
180
|
+
"""Run ``command`` and return its printed output.
|
|
181
|
+
|
|
182
|
+
``pystata.run(..., echo=False)`` prints to the process stdout instead of
|
|
183
|
+
returning a string, so the output is captured by temporarily redirecting
|
|
184
|
+
``sys.stdout``. Without this, output-parsing checks would see ``""`` and
|
|
185
|
+
silently "fail to parse" rather than reporting the real result.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
if self._stata is None:
|
|
189
|
+
raise RuntimeError("StataOracle is not started")
|
|
190
|
+
|
|
191
|
+
import io
|
|
192
|
+
from contextlib import redirect_stdout
|
|
193
|
+
|
|
194
|
+
buffer = io.StringIO()
|
|
195
|
+
with redirect_stdout(buffer):
|
|
196
|
+
self._stata.run(command, echo=False)
|
|
197
|
+
return buffer.getvalue()
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------- capturing
|
|
200
|
+
def capture(self, *, names: Sequence[str] | None = None) -> StataCapture:
|
|
201
|
+
"""Return the current ``e()`` results as a :class:`StataCapture`."""
|
|
202
|
+
|
|
203
|
+
if self._stata is None:
|
|
204
|
+
raise RuntimeError("StataOracle is not started")
|
|
205
|
+
raw = self._stata.get_ereturn()
|
|
206
|
+
|
|
207
|
+
b = None
|
|
208
|
+
V = None
|
|
209
|
+
scalars: dict[str, float] = {}
|
|
210
|
+
macros: dict[str, str] = {}
|
|
211
|
+
|
|
212
|
+
for key, value in raw.items():
|
|
213
|
+
name = _strip_prefix(key)
|
|
214
|
+
if value is None:
|
|
215
|
+
continue
|
|
216
|
+
if name == "b":
|
|
217
|
+
b = np.asarray(value, dtype=float).reshape(-1)
|
|
218
|
+
elif name == "V":
|
|
219
|
+
V = np.asarray(value, dtype=float)
|
|
220
|
+
elif isinstance(value, str):
|
|
221
|
+
macros[name] = value
|
|
222
|
+
elif isinstance(value, (int, float, np.floating, np.integer)):
|
|
223
|
+
scalars[name] = float(value)
|
|
224
|
+
|
|
225
|
+
if b is None or V is None:
|
|
226
|
+
raise RuntimeError(
|
|
227
|
+
"no e(b)/e(V) available; run an estimation command first"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Coefficient order matters: e(b) is a row vector whose *column* stripes
|
|
231
|
+
# hold the names. Read them from Stata rather than inferring, otherwise
|
|
232
|
+
# a comparison could align the wrong coefficients and still "pass".
|
|
233
|
+
coefficient_names = list(names) if names else self._coefficient_names()
|
|
234
|
+
|
|
235
|
+
# Version is recorded because a conformance claim is only meaningful
|
|
236
|
+
# against a specific Stata release. ``display`` writes nothing to r(), so
|
|
237
|
+
# read the value through a local macro instead.
|
|
238
|
+
version = self._stata_version()
|
|
239
|
+
|
|
240
|
+
return StataCapture(
|
|
241
|
+
b=b,
|
|
242
|
+
V=V,
|
|
243
|
+
scalars=scalars,
|
|
244
|
+
macros=macros,
|
|
245
|
+
names=coefficient_names,
|
|
246
|
+
version=version,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def _coefficient_names(self) -> list[str]:
|
|
250
|
+
"""Read the ``e(b)`` column stripes via ``sfi``."""
|
|
251
|
+
|
|
252
|
+
if self._stata is None:
|
|
253
|
+
return []
|
|
254
|
+
try:
|
|
255
|
+
names = self._stata.sfi.Matrix.getColNames("e(b)")
|
|
256
|
+
return [str(name) for name in names]
|
|
257
|
+
except Exception:
|
|
258
|
+
return []
|
|
259
|
+
|
|
260
|
+
def _stata_version(self) -> str:
|
|
261
|
+
"""Return ``c(stata_version)``, or ``""`` when it cannot be read.
|
|
262
|
+
|
|
263
|
+
pystata exposes no direct Python accessor for ``c()`` macros and
|
|
264
|
+
``display`` writes nothing to ``r()``, so this deliberately returns an
|
|
265
|
+
empty string rather than scraping stdout. Record the version in the
|
|
266
|
+
evidence registry by hand instead; a silently wrong version string would
|
|
267
|
+
be worse than an absent one.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
return ""
|