The maintainers of `rdlocrand` released 3.0 on 2026-10-04. Every reference
fixture was regenerated against it. The window sequences StatsPAI already
built (those of release 1.0) are the ones 3.0 returns, to the last digit.
Notes are in `docs/dev/2026-10-04-cit-rd-extensions-review.md`.
⚠️ Correctness
- **`sp.rdrandinf`, `sp.rdwinselect` and `sp.rdsensitivity` with `p > 0`
use HC3 for the large-sample test; they used HC2.** `rdlocrand` 3.0 added
`vce = "HC1" | "HC2" | "HC3"` with HC3 as the default, and the same
argument is new here (`vce='hc1' | 'hc2' | 'hc3'`). All three agree with
3.0 to 7e-13 across polynomial orders, kernels, a non-zero null and
evaluation points away from the cutoff. On the U.S. Senate data, for
`termshouse` with `p = 1` in [-1, 1], the large-sample p-value moves from
0.666 to 0.694.
`vce='hc2'` returns the earlier numbers. Calls with `p = 0`, the default,
are unchanged, and so is the randomization p-value.
Changed
- `sp.from_stata` / `sp.stata` read `vce()` on `rdrandinf` and
`rdwinselect`. With `p() > 0` and no `vce()` the translation notes that
releases up to 2.0 used HC2.
- `tests/reference_parity/test_rdlocrand_v1_parity.py` runs against both
1.0 and 3.0. `sp.rdwinselect(approx=True, p=1)` has reference numbers for
the first time (2.0 stopped with an error there).
- With `p > 0`, 3.0 no longer computes a randomization p-value.
`sp.rdrandinf` keeps its own, which permutes outcomes against (score,
assignment) pairs and holds its level in simulation.
What the examples of Wooldridge's *Introductory Econometrics* found
Every example of the book that the companion scripts of Heiss compute was
written with StatsPAI and compared with statsmodels, linearmodels, Stata 18
or R on the book's datasets. Most of it agreed to 1e-12. The notes are in
`docs/dev/2026-10-05-wooldridge-review.md`; the Stata and R numbers are in
`tests/test_wooldridge_examples.py`.
⚠️ Correctness
- **`sp.synth(method='classic')` reported a confidence interval that could
contradict its own p-value.** `pvalue` is the rank of the treated
post/pre RMSPE ratio among the placebos; `ci` was the estimate plus or
minus a normal quantile times the standard deviation of the placebo
ATTs, a different procedure with no guarantee of agreeing with the test.
`ci` is now the interval the test implies: the constant effects that
the same rank test does not reject (Firpo and Possebom 2018), with exact
ends. It excludes zero exactly when `pvalue < alpha`. On Proposition 99
without covariates (p = 3/39 = 0.077) the 95% interval goes from
[-40.7, 1.6] to [-58.6, 19.6] and the 90% one from [-37.3, -1.8] to
[-33.0, -5.3]. With fewer than `1 / alpha - 1` donors no effect can be
rejected and the interval is `(-inf, inf)`; it used to be finite. The
estimate, `se` and `pvalue` are unchanged, the old interval is in
`model_info['ci_normal']`, and `model_info['ci_method']` says which one
`ci` is. `summary()` now prints an infinite end as `-inf` / `inf`
instead of leaving it blank, for every estimator. See `MIGRATION.md`.
- **DFBETAS from `sp.estat(result, 'leverage')` were on the wrong scale.**
The division by the square root of the diagonal of `(X'X)^-1` was
missing, so each value was the change in the coefficient over the
leave-one-out residual standard deviation, not over the standard error of
the coefficient. The `2/sqrt(n)` rule was then applied to that quantity,
and `dfbetas_flagged_obs` listed the wrong observations. The values now
agree with Stata's `dfbeta` and with statsmodels to 1e-8. Leverage and
Cook's distance were right and are unchanged. See `MIGRATION.md`.
- **`predict(new_data)` after `sp.glm` returned the linear index.** With
no argument the same method returned the fitted means, so the two calls
were on different scales for every link but the identity. Both are now
the mean of the outcome; `what='link'` gives the index. After
`sp.poisson` and `sp.nbreg` the call used to fail on the `_cons` column
and now works, with the `offset=` or `exposure=` of the new rows applied.
See `MIGRATION.md`.
- **`sp.from_stata` translated `heckman` to the two-step estimator.**
Stata's `heckman` is maximum likelihood unless `twostep` is given, and
`twostep` itself was reported as an untranslated option. The translation
now writes `method='ml'` or `method='twostep'`. On the `mroz` data the
coefficient on education is 0.10835 by maximum likelihood and 0.10907 by
the two-step estimator; both now match Stata. `sp.heckman` itself is
unchanged. See `MIGRATION.md`.
- **`sp.estat(result, 'endogenous')` and `'overid'` said "p-value not
available"** in their interpretation, next to the p-value they reported.
They now state the conclusion.
Added
- **Formulas.** `log`, `log2`, `log10`, `log1p`, `exp` and `sqrt` can be
written without the `np.` prefix in every formula (`log(wage) ~ educ`).
A transformed outcome is accepted by `sp.ivreg` and `sp.panel`, a
transformed endogenous regressor by `sp.ivreg`
(`hours ~ educ + (log(wage) ~ exper + I(exper**2))`), and `sp.qreg`
takes transformed, factor and interaction terms. A name the data do not
have is reported by name; it used to end in a `KeyError`.
- **`result.predict(data, what=, alpha=)`** for the results of
`sp.regress`, `sp.ivreg`, `sp.glm`, `sp.poisson` and the other
formula-based fits. The design is rebuilt from the formula, so new data
need the raw variables only. `what='confidence'` returns the standard
error and interval of the conditional mean, `what='prediction'` the
interval for a new observation of a linear model, `what='link'` the
index. The results of `sp.logit` and `sp.probit` take a DataFrame.
- **`sp.estat`**: `'archlm'` (Engle's LM test), `'durbinalt'` (Durbin's
alternative test), `'bgodfrey'` with `version='fstat'`, and `'white'`
with `variables='fitted'` (the form of the test on the fitted values and
their squares). `'leverage'` also returns `rstudent` and `rstandard`.
`'beta'` returns the standardized coefficients of Stata's
`regress, beta`.
- **`sp.tobit(formula=)` and `sp.truncreg(formula=)`**, with transformed,
factor and interaction terms; the formula may also come first.
`sp.survreg` accepts the data first.
- **`sp.glm(scale=)`**: `'x2'`, `'dev'` or a number, the factor on the
model-based covariance. `family='poisson', scale='x2'` is the
quasi-Poisson covariance and matches Stata `glm, scale(x2)`.
- **`sp.test`** takes a list of restrictions and coefficient names that
hold operators or blanks (`I(exper ** 2)`, `np.log(sales)`); so does
`sp.lincom`.
- **`sp.lrtest`** accepts the maximum-likelihood regressions (`sp.logit`,
`sp.probit`, `sp.poisson`, `sp.nbreg`, `sp.tobit`, `sp.glm`). It refuses
fits on different samples, different models, and a reversed order.
- **Stata translations**: `estat archlm`, `estat durbinalt`, `truncreg`,
`glm` (`family()`, `link()`, `scale()`, `vce()`), and `vce(robust)` /
`vce(cluster)` on `heckman`.
Fixed
- `sp.panel(method='fd')` with the time variable among the regressors (a
linear trend) raised `KeyError`.
- `sp.panel(method='fe')` and `'twoway'` raised when the effects absorbed a
regressor. The regressor is now omitted with a warning and listed in
`model_info['omitted']`, as Stata and R do.
- `sp.panel(method='mundlak')` raised on a time-invariant regressor or on
period dummies. Unit means that add no rank are left out and listed in
`model_info['cre_means_omitted']`.
⚠️ Correctness
- **`sp.stata` / `sp.from_stata` translated `ivregress gmm` to a
different estimator.** Stata's two-step GMM uses the heteroskedasticity
-robust weight matrix unless told otherwise; the translation called
`sp.ivreg(method='gmm')`, which uses the unadjusted one, and dropped
`wmatrix()`. On Card's schooling data the schooling coefficient came
out 0.1608 against Stata's 0.1588. The translation now follows
`wmatrix()` and `vce()` as Stata 18 resolves them (a `vce(cluster c)`
alone sets the weight matrix too), and nine option combinations
reproduce `ivregress gmm` to 5e-9 through `sp.stata`
(`tests/reference_parity/test_iv_gmm_stata_parity.py`). Combinations
with no counterpart (`wmatrix(hac ...)`, `igmm`, a cluster weight matrix
with a robust variance, `wmatrix(unadjusted) small`) are reported as
untranslated and `sp.stata` refuses them. Calls to `sp.iv` /
`sp.ivreg` themselves return the same numbers as before.
- **`sp.ipw(estimand='ATT' | 'ATC', normalize=False)` was scaled by the
share of the target group.** The Horvitz-Thompson sums were divided by
`n` for every estimand, so the ATT came back multiplied by `P(T=1)` and
the ATC by `P(T=0)`. They are now divided by the number of treated and
of controls. On the NHANES school-meal data of Ding (2024, chapter 13)
the ATT goes from -1.098 to -1.992, the value of the book's own code.
`normalize=True` (the default) and the ATE are unchanged. See
`MIGRATION.md`.
- **The confidence interval of `sp.fisher_exact` was too short.** It was
read off a 101-point grid six outcome standard deviations wide, each
point tested on its own 500 draws, and with `controls` the shifted
outcome was not residualized. It is now the exact inversion of the
randomization test on the assignments that gave the p-value, each end
found by bisection. On the LaLonde experiment the 95% interval goes
from [999, 2589] to [572, 3015]; effects of 1000 and 2600 have
randomization p-values of 0.21 and 0.20 and belong in it. The interval
is infinite, with a warning, when the design has too few assignments to
reject at `alpha`. p-values are unchanged. See `MIGRATION.md`.
- **`sp.gformula_ice_fn` raised `LinAlgError` on a flat confounder list.**
The documented shorthand for "the same confounders at every time", and
any covariate named at more than one time, entered the history twice and
made the default sandwich standard error singular. Repeated names are
now counted once. Point estimates are unchanged.
- **Lee and Zhang-Rubin bounds warn when ties defeat the trimming.** On a
binary or coarsely discrete outcome `trimming='quantile'` keeps every
observation tied at the quantile, trims almost nothing, and the two
bounds collapse (both -0.045 where the sharp bounds are [-0.176,
-0.019] on the trial of Ding's chapter 26). `sp.lee_bounds`,
`sp.principal_strat`, `sp.survivor_average_causal_effect` and
`sp.attrition_bounds` now say so and point to `trimming='exact'`. The
default and its numbers are unchanged.
- **For `statistic='ks'` and `'rank_sum'` the "confidence interval" of
`sp.fisher_exact` was not an interval for the effect.** It was the 2.5%
and 97.5% percentiles of the null distribution of the statistic, centred
on zero whatever the effect. It is now the same constant-effect
inversion as for the difference in means, in outcome units: the
statistic is recomputed on `Y - tau_0 * D` for a stored set of
assignments and each end is bisected. With 12 treated among 60 units
and a constant effect of 0.75 the 95% interval covers 96.0% (rank sum)
and 96.7% (KS) of the time over 150 draws. The same holds for
`statistic='t'`. p-values are unchanged. See `MIGRATION.md`.
- **Arithmetic inside a formula wrapped around on small integer
columns.** `pd.read_stata` returns a Stata `int` as `int16` and a `byte`
as `int8`, and numpy keeps arithmetic in that type, so `I(x**2)` was a
column of garbage for values above 181 (or 11) and the regression was
fitted on it without a warning. On the restaurant-inspection data of
Huntington-Klein's *The Effect* (a count up to 646) the coefficient of
the quadratic model was -0.0171 where Stata and R give -0.0844. It
affected `sp.regress`, `sp.feols`, `sp.fepois`, `sp.feglm`, `sp.glm`,
`sp.logit`, `sp.ivreg`, `sp.panel`, the covariate formulas of the DiD
family and the spatial models. Narrow integer columns are now widened to
`int64` before a formula is evaluated
(`tests/test_narrow_integer_formula.py`). Frames whose integers are
already `int64` or float are unaffected. See `MIGRATION.md`.
- **`sp.ebalance(moments=2)` with a 0/1 covariate returned uniform
weights.** The square of an indicator is the indicator, the dual was
singular, and the estimate reported was the raw difference in means
behind a `ConvergenceWarning`. A moment that repeats another one is now
left out (`model_info['redundant_moments']`) and the weights balance the
rest exactly.
- **`sp.match(method='cem')` follows the `cem` packages.** Sturges' rule
gives the number of cut points, hence one bin fewer than before, and a
bin is closed on the right. The default now reproduces Stata `cem` on
the NSW data (matched sample and estimate). Estimates from the default
change, and so do estimates with integer covariates under an explicit
`n_bins`. See `MIGRATION.md`.
- **`sp.sensemakr` skipped a benchmark name it did not find** and returned
a table without that row. It is now an error.
- **`sp.stata` / `sp.from_stata`, four silent mistranslations of factor
notation.** `abc` was written as the main effects and the three-way
product, without the two-way terms. A variable with no prefix inside ``
or `` was read as continuous where Stata reads a factor. `ab` without
main effects was written as the product of two columns; it is now
refused, because the formula's coefficients would not be Stata's.
`i.pc.xc.x` lost the square. All four now follow Stata and are
pinned against Stata 18
(`tests/reference_parity/test_the_effect_stata_parity.py`).
- **`sp.stata`: `collapse` stores a mean, median or sd as Stata does**
(float unless the source is a long or a double). Regressions on
collapsed data differed from Stata in the sixth digit.
- **`sp.survreg` ignored `robust=` and `cluster=`.** Both were accepted
and written into `model_info`, and the standard errors were the
observed-information ones whatever was asked. They are now the sandwich
and the cluster sandwich, with Stata's factors, and agree with Stata 18
`streg ..., time vce(robust)` and `vce(cluster)` to 1e-6 for the
Weibull, exponential, log-normal and log-logistic models
(`tests/reference_parity/test_survreg_streg_stata_parity.py`). On the
test data the robust standard error of the Weibull scale is 28% larger
than the one that used to be returned under `robust='robust'`. Fits
without these options change in the sixth digit only: the optimiser now
finishes with Newton steps (coefficients were about 1e-5 from the
optimum) and agrees with Stata to 1e-9. See `MIGRATION.md`.
- **The Vuong statistic reported by `sp.zip_model` and `sp.zinb` was too
large.** `diagnostics['vuong_stat']` compared the zero-inflated fit with
a Poisson (negative binomial) density evaluated at the zero-inflated
model's own count coefficients. That is not the maximum of the
comparison model's likelihood, so the comparison model looked worse than
it is. It is now the regression fitted on its own, as in R
`pscl::vuong` and in Vuong (1989). On the test data the statistic goes
from 13.42 to 8.69 for the Poisson pair and from 9.16 to 3.70 for the
negative binomial pair, and both now agree with R and with Stata 18
(`forcevuong`) to 1e-6. Coefficients,
standard errors and log-likelihoods are unchanged. The docstrings also
say that this statistic is not a valid test for zero inflation (Wilson
2015). See `MIGRATION.md`.
Added
From a pass over Huntington-Klein, *The Effect* (2nd ed.): its Stata code
for chapters 4 and 13 to 21 was run in Stata 18 and replayed through
`sp.stata` (836 printed numbers reproduced, 148 before). Notes are in
`docs/dev/2026-10-05-the-effect-2e-review.md`.
- `sp.ebalance(moments=[2, 2, 1])` takes one order per covariate (Stata
`targets(2 2 1)`), and `dof_adjust=True` matches the sample variance
and skewness as Stata `ebalance` does. With it the weights agree with
`ebalance, tolerance(1e-10)` to 1e-10. The default is unchanged.
- `sp.match(method='cem', n_bins=...)` takes a list or a dict, from
covariate to a number of bins or to cut edges.
- `sp.sensemakr(benchmark={'label': [columns]})` benchmarks several
controls as one group (`gbenchmark()` / `gname()` in Stata). Agrees with
Stata `sensemakr` to 1e-12.
- `sp.rdrobust` names a covariate, outcome or running variable that is not
numeric instead of failing inside numpy.
- `sp.rosenbaum_bounds(estimates=True)` adds, for each Gamma, the range of
the Hodges-Lehmann point estimate and the outer ends of its confidence
interval (the `t-hat` and `CI` columns of Stata `rbounds`). The
significance levels and the confidence bounds agree with Rosenbaum's
`DOS2::senWilcox`, the point-estimate bounds with Stata `rbounds`.
`sp.stata` runs `rbounds diff, gamma()` and `bstat, stat()`.
- `sp.stata` runs `xi`, `ebalance`, `cem`, `sensemakr` and
`table g, statistic(mean x)`; `encode, g()`;
`date()`; `egen cut(), group()`; `reghdfe` with `dib3.t` and squares;
`_b[1.d3.t]`; abbreviated variable names; weights that are missing or
zero (the observation is dropped, as in Stata); `[iw = w]` in `regress`
when the weights sum to the number of observations; `i.` covariates in
`psmatch2` and `teffects`; `program def`, `return scalar` of a local
macro and nested programs. Translated `tabstat, by()` and `table` head
the groups by their values; they used to read "Control" and "Treated"
for any 0/1 grouping variable.
Seven functions from a pass over Croissant (2025), *Microeconometrics with
R*, and its companion package `micsr`. The book's chapters on binary,
count and censored outcomes use estimators StatsPAI did not have. Notes
are in `docs/dev/2026-10-04-croissant-microeconometrics-review.md`.
- **`sp.ivprobit` and `sp.ivtobit`** fit a probit and a tobit with
continuous endogenous regressors. Maximum likelihood is the default and
`method='twostep'` gives Newey's minimum chi-squared estimator, as in
Stata's `ivprobit` and `ivtobit`. Both report the Wald test of
exogeneity and take `vce='robust'` or `cluster=`. Coefficients,
standard errors, the log-likelihood and both Wald statistics agree with
Stata 18 to 1e-6 on 14 blocks, with one and two endogenous regressors
and with one and two censoring limits
(`tests/reference_parity/test_ivprobit_ivtobit_stata_parity.py`).
- **`sp.ivpoisson`** fits an exponential-mean model with endogenous
regressors by GMM, as Stata's `ivpoisson gmm`. `errors='multiplicative'`
is Mullahy's (1997) moment condition, the one that is valid when an
omitted variable enters the exponential. One-step, two-step and iterated
GMM, robust, clustered and unadjusted weight matrices, Hansen's J. It
agrees with Stata 18 to 1e-6 on 12 blocks
(`tests/reference_parity/test_ivpoisson_stata_parity.py`).
- **`sp.cmtest`** runs the conditional moment tests of normality,
heteroskedasticity, skewness and kurtosis on a fit from `sp.probit` or
`sp.tobit`. Those models are inconsistent when the latent error is not
normal or not homoskedastic, and they leave no residual to inspect. The
statistics agree with R `micsr::cmtest` to 1e-9 for the tobit and 1e-6
for the probit, and reject 5% of the time under the null in simulation
(`tests/reference_parity/test_cmtest_micsr_parity.py`).
- **`sp.nlogit`** fits the nested logit in its random-utility form on
long-format choice data, with one dissimilarity parameter per nest or a
common one, alternative-specific constants, and the likelihood-ratio
test of IIA against the conditional logit. Coefficients, standard
errors (oim, robust, cluster) and the test agree with Stata 18 `nlogit`
to 1e-6, and coefficients and likelihoods with R `mlogit`. `mlogit`'s
standard errors differ from Stata's by up to 4% on the same estimates;
StatsPAI's are Stata's (`tests/reference_parity/test_nlogit_parity.py`).
- **`sp.etpoisson`** fits a Poisson regression with an endogenous binary
treatment by maximum likelihood, as Stata's `etpoisson`. The outcome
error is integrated out by Gauss-Hermite quadrature (`intpoints=24`).
It reports the correlation of the two errors, the Wald test of
independent equations and the average treatment effect on the count
scale with a delta-method standard error. Coefficients and standard
errors agree with Stata 18 to 1e-6 (observed 1e-10) and the ATE with
`margins r.treat` to 1e-9
(`tests/reference_parity/test_etpoisson_stata_parity.py`).
`sp.from_stata` translates the command.
- **`sp.tobit(method='scls')`** is Powell's symmetrically censored least
squares. It needs a symmetric error and nothing else, so it stays
consistent under heteroskedasticity and non-normality, which is what to
turn to when `sp.cmtest` rejects. In a simulation with heteroskedastic
errors its slope averages 1.01 for a true value of 1 and its 95%
interval covers 97% of the time, where maximum likelihood averages 1.33.
Coefficients agree with `micsr::tobit1(method = "trimmed")` to 1e-8
(`tests/reference_parity/test_binary_r2_and_scls_parity.py`).
- **`sp.probit` and `sp.logit` report a family of goodness-of-fit
measures** in `model_info['r2']`: McFadden, Cox-Snell, Nagelkerke,
Efron, Tjur, Estrella and McKelvey-Zavoina. They agree with R
`DescTools::PseudoR2` and `micsr::rsq` to 1e-6. `model_info['pseudo_r2']`
is unchanged.
- **`sp.survreg(frailty='gamma')`** adds gamma-distributed unobserved
heterogeneity to the four parametric duration models, as Stata's
`streg, frailty(gamma)`. It reports `log(theta)`, the frailty variance
and the likelihood-ratio test of `theta = 0` with its boundary p-value.
Coefficients, standard errors, `theta` and the test agree with Stata 18
to 1e-6. When the variance goes to its boundary the fit is the model
without frailty and a warning says so. `sp.survreg` results also carry
`data_info['llobs']`, so `sp.vuong` can compare two duration
distributions, and `model_info['ll_log_time']`, the log-likelihood in
Stata's convention.
- **`sp.vuong`** compares two non-nested maximum likelihood fits on the
same observations and returns the Vuong statistic with its AIC and BIC
corrections. It takes fits from `sp.poisson`, `sp.nbreg`,
`sp.zip_model`, `sp.zinb`, `sp.hurdle`, `sp.logit` and `sp.probit`,
which now carry their per-observation log-likelihood in
`data_info['llobs']`. Statistics agree with R `pscl::vuong` to 1e-6 on
five model pairs. `pscl` leaves a negative binomial dispersion out of
its parameter count and StatsPAI does not, so the corrected statistics
differ when exactly one model is negative binomial
(`tests/reference_parity/test_vuong_pscl_parity.py`).
- `sp.stata` reads `stset timevar, failure(eventvar)` and runs
`streg ..., distribution() time [frailty(gamma)] [vce()]` through
`sp.survreg`. `streg` for the Weibull or exponential without `time` is
the proportional-hazards metric and is refused, as are `shared()`,
other frailty distributions, and `stset` with `id()`, `enter()` or
`origin()`.
- `sp.ologit`, `sp.oprobit`, `sp.mlogit`, `sp.tobit` and `sp.nlogit` fits
carry their per-observation log-likelihood, so `sp.vuong` takes them.
- `sp.from_stata` and `sp.stata` translate `ivprobit`, `ivtobit` and
`ivpoisson gmm`. `ivtobit` without `ll()` is written as `ll=None`,
because `sp.ivtobit` censors at zero by default and Stata does not.
`ivpoisson cfunction` is refused.
What the syllabus of Xu and Lan's *Causal-Inference Econometrics* found
徐小君、蓝嘉俊《因果推断计量经济学》(清华大学出版社, 2025) publishes no data
and no code, only a sample chapter and the table of contents. The twelve
chapters were therefore used as a syllabus. One Stata do-file per chapter
runs the commands those methods are taught with, on datasets that ship with
Stata, and each log was replayed through `sp.stata` number by number
(`tests/external_parity/xu_lan_syllabus/`). About 1,200 printed numbers
were reproduced at the start and two blocks were not. 85 commands were
refused, and several of the functions behind them had never been compared
with Stata. Three of the findings were made by other passes the same
night and fixed on main first: `ivregress gmm` translating to 2SLS,
`heckman` translating to the two-step estimator, and the missing
`estat durbinalt` / `estat archlm`. Those fixes are theirs (see the
entries above); this pass keeps a second set of Stata numbers for each.
Notes are in
`docs/dev/2026-10-05-xu-lan-causal-econometrics-review.md`; the numbers
below are pinned on committed synthetic data in
`tests/reference_parity/test_textbook_syllabus_stata_parity.py` (40 tests).
**⚠️ Correctness**
- **`sp.arima` fitted a zero-mean model to a series that has a mean.** The
default (`method='statespace'`) estimated no constant, so an AR(1) on a
series with a non-zero mean pushed the autoregressive coefficient towards
one. On the first difference of the log U.S. wholesale price index the
coefficient was 0.763; with the mean estimated it is 0.619 (Stata: 0.615
by exact likelihood). `sp.arima` now has `trend=`. The default estimates
a constant when the series is not differenced and none when it is, as R's
`stats::arima` and statsmodels' `ARIMA` do; `trend='c'` on a differenced
series estimates the drift Stata's `arima` always includes; `trend='n'`
gives the old fit. `method='innovations_mle'` already estimated the
constant and is unchanged. See `MIGRATION.md`.
- **Two-step standard errors of `sp.heckman` now use the observed
information of the first-step probit.** They used the expected
information. The difference is in the fourth digit (0.60627 to 0.60654
for the inverse Mills ratio on Stata's `womenwk`), and it was the whole of
the gap that Track A module 43 carried as a tolerance. The worst relative
gap in a standard error against R `sampleSelection` and Stata goes from
8.6e-5 to 4e-9, and the module's budget is now the default 1e-6.
- **`sp.stata("tobit y x, ul(2)")` censored from below as well.** Stata
censors only on the sides that are written. The translation passed the
upper limit and left `sp.tobit` at its own default of `ll=0`, so every
outcome at or below zero was treated as censored too. `tobit y x` with
no limit (an uncensored normal model in Stata) became a tobit at zero,
and `ll` without a value or with a variable name fell back to zero
silently. Both limits are now written out, `sp.tobit` accepts
`ll=None`, and the forms that cannot be translated are refused. On a
sample censored from above only, the slope goes from 0.965 to 0.998
(truth 1), equal to Stata 18 to 1e-8. See `MIGRATION.md`.
- **`sp.tab` reported a Yates-corrected statistic as "Pearson chi2" on
2 x 2 tables.** The test came from `scipy.stats.chi2_contingency` at its
default, which applies the continuity correction when the table has one
degree of freedom. The label, Stata's `tabulate, chi2` and R's
`chisq.test(correct = FALSE)` all mean the uncorrected statistic. On the
test table it is 11.84, not the 11.11 that was printed. Larger tables
are unchanged. See `MIGRATION.md`.
- **`sp.garch(p >= 1, q=0)` returned numbers for a model that is not
identified.** With no ARCH term the variance never responds to the data
and every value of the GARCH coefficient gives the same likelihood. The
call is now refused. (`p` counts lagged variances and `q` lagged squared
innovations; ARCH(1) is `p=0, q=1`.)
**Added**
- **`sp.sdtest` and `sp.ztest`.** The chi-squared test that a standard
deviation equals a value, the F test that two are equal, and the z test
on a mean or a difference of means when the standard deviation is known.
These are the two worked examples of the book's first chapter. Both take
data or summary statistics (`sp.sdtest(n=10, sd=1.14, sd0=2)`), as
Stata's `sdtest` / `sdtesti` and `ztest` / `ztesti`.
- **`sp.prtest`, `sp.sktest`, `sp.swilk` and `sp.ci`.** The rest of a
first statistics chapter: the z test on one or two proportions, the
skewness-kurtosis and Shapiro-Wilk tests for normality, and confidence
intervals for means, variances, standard deviations and proportions
(exact, Wald, Wilson, Agresti-Coull, Jeffreys). They agree with Stata 18
`prtest`, `sktest`, `swilk` and `ci` to 1e-8 or better (the Shapiro-Wilk
p-value to 1e-5). `sp.stata` runs those commands and `prtesti`.
- **`svar ..., aeq(A) beq(B)` and `svar ..., lreq(C)`** run in `sp.stata`,
with the matrices defined by `matrix A = (...)` before them.
- **`tabulate a b, chi2 exact lrchi2 V`** runs in `sp.stata`. The Pearson
and likelihood-ratio statistics, Cramer's V and Fisher's exact p-values
are in `.attrs['test']` of the table and in `r()`; `sp.tab(...,
output='dataframe')` carries the same dictionary.
- **`sp.nlcom`.** A nonlinear function of the coefficients with a
delta-method standard error: ratios, the long-run effect `b / (1 - c)` of
a partial-adjustment model, turning points. The expression is parsed into
a syntax tree and differentiated node by node; nothing is passed to
`eval`.
- **`sp.svar`.** Structural VAR on a fitted `sp.var`: short-run
restrictions in the AB model (`A=`, `B=`), long-run restrictions
(`long_run=`), or sign restrictions on the impulse responses (`sign=`).
Coefficients, standard errors, the log-likelihood, the test of
over-identifying restrictions, structural impulse responses and variance
decompositions agree with Stata 18 `svar` to 1e-6 in all three
likelihood cases. Sign restrictions return the set of admissible
rotations, and the documentation says what its bands are not: they carry
no sampling uncertainty. `VARResult.fevd()` gives the Cholesky variance
decomposition.
- **`sp.unitroot(test='pp' | 'kpss')`.** Phillips-Perron `Z(t)` and
`Z(rho)` and the KPSS statistic, equal to Stata's `pperron` and `kpss` to
1e-9. KPSS has stationarity as its null; the result says so in `null`.
- **Stata's factor-variable names in `sp.test` and `sp.lincom`.**
`"2.race = 3.race"` and `"x + 1.unionc.x"` find the coefficients the
formula engine calls `C(race)[T.2]` and `x:C(union)[T.1]`. `"i.race"` on
its own tests every level jointly.
- **`sp.heckman(select=None)`** selects where the outcome is observed
(Stata's `heckman y x, select(z1 z2)`, which `sp.stata` now runs), and
`method='mle'` is accepted for `'ml'`. `sp.arima` and `sp.garch` take
`data=` with a column name.
- **`sp.hdfe_ols(absorb_dof='areg')`** (and `sp.absorb_ols`) charges every
absorbed level in a clustered variance, where the default follows
`reghdfe` and does not charge an effect nested in the cluster variable.
With `drop_singletons=False` the fit is then the dummy-variable
regression, slopes and standard errors equal to
`sp.regress('y ~ x + C(g)')` to 1e-10 under the classical, robust and
clustered variances, with and without weights, without building the
dummies. `sp.stata` sends `areg` there: on the NLS panel (4,134 groups)
the command goes from about three minutes to under a second, and its
`_cons` is now Stata's (the dummy-variable translation reported the
first group's level and said so in a note). The result no longer lists
the group dummies. A robust variance with weights and HC2 / HC3 keep
the dummy-variable translation.
- **`sp.ttest(n=, mean=, sd=)`** takes summary statistics for one sample
or two independent ones (Stata's `ttesti`).
- **Abbreviated variable names in `sp.stata`** are spelled out in
`heckman`, `truncreg`, `etregress` and `prais`, and inside the options
that hold a varlist: `select()`, `treat()`, `absorb()`, `by()`,
`cluster()` and `vce(cluster ...)`. An abbreviation that fits two
variables is refused, as in Stata.
- **`sp.stata` runs** `sdtest`, `sdtesti`, `ztest`, `ztesti`, `ttesti`,
`etregress`, `pperron`, `kpss`, `arima`, `arch`, `testparm` and `nlcom`.
**Fixed**
- **`xtreg y x i.year, fe` through `sp.stata` failed on a panel with fewer
units than regressors.** The fixed-effects statistics went through the
between regression of the random-effects variance components, which does
not exist there.
- **`sp.panel(method='mle')` took minutes on a few thousand panels.** It
called the general mixed-model optimiser. The likelihood of the
random-intercept model is now concentrated in one variance ratio. On the
NLS panel (4,134 women, 19,007 rows) the fit goes from more than 150
seconds to 0.3, with the same estimates to 1e-8.
- **`sp.regress` with thousands of dummy columns spent minutes checking
for collinearity.** The scan took one dot product per pair of columns
and regrew its Cholesky factor one row at a time. It now works on blocks
of the cross-product. With about 4,100 dummy columns the two checks go
from more than 400 seconds to about 40; the omitted columns are the
same.
What Gaillac and L'Hour's *Machine Learning for Econometrics* found
A pass over the companion code of Gaillac and L'Hour (2025, OUP): notebooks
and R scripts for chapters 2 to 11. Notes, what was run and what is left
open are in `docs/dev/2026-10-05-gaillac-lhour-ml4econometrics-review.md`.
Most of the book already had a counterpart that reproduces its references
(`hdm::rlassoIV`, `ivmodel` LIML and Fuller on the Card and BLP data to
1e-8; the double-selection simulation of chapter 5 gives bias 0.017 and
coverage 0.949 against the book's 0.012 and 0.942). Three things did not.
- **Cluster-Lasso for panels (chapter 7): `cluster=` on `sp.rlasso`,
`sp.rlasso_effect`, `sp.rlasso_effects` and `sp.rlasso_iv`.** The
penalty loadings of Belloni, Chernozhukov, Hansen and Kozbur (2016) sum
the scores within cluster before squaring, `gamma` defaults to
`0.1 / log(G)`, and the final standard error is cluster-robust. hdm has
no such option and the book sources an unpublished `rlasso_cluster.R`.
Against Stata's lassopack on the same bytes: the penalty level, the
selected variables and the post-Lasso coefficients of `rlasso,
cluster()` agree to 1e-10, and the point estimates and variances of
`pdslasso, cluster()` (double selection and partialling out) and
`ivlasso, cluster()` (selection among instruments, and among both
instruments and controls) agree to 1e-9
(`tests/reference_parity/test_cluster_lasso_stata.py`). Variances carry
no small-sample factor, as in `pdslasso`. What ignoring the clustering
costs: in a within-transformed panel of 100 units and 8 periods with
AR(1) regressors and errors (coefficient 0.8), double selection without
`cluster=` keeps 6.1 controls on average and its 95% interval covers
78.6% of the time; with `cluster=` it keeps 4.6 and covers 93.3% (1,167
draws). On one of the two test panels lassopack and hdm's iteration stop
at different supports. The test shows that both are fixed points of the
loading iteration under StatsPAI's cluster loadings, so the difference
is the path and not the penalty; this also happens without clustering.
- **Classic synthetic control: interval and p-value from one procedure
(chapter 10).** The book's end-of-chapter question 5 is that a test
which rejects and an interval containing zero cannot come from the same
procedure. In `sp.synth(method='classic')` they did not. See the
correctness entry above; the inversion is
`statspai.synth._core.placebo_inversion_ci`, checked against a
brute-force grid. Under `H0: effect = C` the weights do not move, the
treated gap becomes `gap - C` and the gap of a placebo whose donor pool
holds the treated unit with weight `w` becomes `gap + w C`, so each
comparison is a quadratic inequality in `C`.
- **`sp.datasets.nsw_dw()` said it was the Dehejia-Wahba data.** It is a
simulated replica with a built-in latent effect of $1,794 (the treated
mean of `re78` is 5,508; in the real file it is 6,349). The docstring
now opens with that and the frame carries `attrs['simulated'] = True`.
`sp.datasets.nsw_dw(simulated=False)` returns the real sample: the 185
NSW treated and the 2,490 PSID-1 comparison men, bundled, no network.
Its naive difference in 1978 earnings is -$15,205, Table 3 of Dehejia
and Wahba (1999), and it agrees row by row with the files on Dehejia's
NBER page to the rounding of earnings. The bare call is still the
replica, rows unchanged, because parity fixtures are built on it.
Changed
- `sp.survreg` raises `MethodIncompatibility` when a duration is zero or
negative. It used to add `1e-15` to every duration and fit.
Fixed
- **`sp.zip_model` and `sp.zinb` returned NaN for every standard error
when one coefficient of the inflation equation was not identified.**
Under quasi-complete separation that coefficient runs off, the logistic
function overflowed inside the complex-step Hessian, and one NaN spread
to the whole matrix. On the `trips` data of the book, R
`pscl::zeroinfl` reports standard errors for the other 19 coefficients
and StatsPAI's are now within 0.5% of them. `sp.hurdle` was not
affected in its numbers. All three now warn and name the coefficient
the likelihood is flat in (`tests/test_zeroinflated_separation.py`).
Estimates on data without separation do not change.
What Ding's *A First Course in Causal Inference* found
The 28 R programs of the book write every estimator out by hand. Each
deterministic number was recomputed with the matching `sp.*` call
(`tests/external_parity/test_ding_first_course.py`, 19 tests over 17
chapters; review in `docs/dev/2026-10-05-ding-first-course-review.md`,
guide in `docs/guides/ding_first_course.md`). The four corrections are
listed above. What was added:
- `sp.rosenbaum_bounds(method='t')`: Rosenbaum's sensitivity analysis for
the mean pair difference. Agrees with `sensitivitymw::senmw(method =
"t")` to 2e-14.
- `sp.anderson_rubin_test(ar_vcov='HC0' | 'HC1' | 'HC2' | 'HC3')`: the
Anderson-Rubin test and interval with a heteroskedasticity-robust
variance (the Fieller-Anderson-Rubin interval of the book). The
statistic was the homoskedastic F unless `cluster=` was given, and
`vcov` only ever chose the effective F reported beside it. Agrees with
`sandwich::vcovHC` on the reduced form to 1e-9 for one and two
instruments. The default stays `'classic'`.
- `sp.ri_test(stat='rank_sum' | 'lin' | 'lin_t')` and
`sp.fisher_exact(statistic='t')`. `'lin_t'` is Lin's estimate over its
HC2 standard error, the covariate-adjusted statistic whose
randomization test is also valid for the weak null.
- `sp.randomize(rerand_accept=)`: rerandomization by Morgan and Rubin's
criterion. Assignments are redrawn until the Mahalanobis distance,
scaled by `n1 n0 / n`, is below the chi-square quantile of the
acceptance probability; `result.rerandomization` records it. The
`n_rerand` search is unchanged, and its documentation now says that its
threshold is not on that scale.
- `sp.lm_lin(blocks=)`: Lin's estimator fitted in each block of a
stratified experiment and combined by block shares.
- `sp.fisher_exact(...).summary()` prints the confidence level that was
asked for and no longer calls the null-distribution quantiles of
`statistic='ks'` / `'rank_sum'` / `'t'` a confidence interval.
`FisherResult` has an `alpha` attribute.
- `sp.principal_strat(method='principal_score')` recognises one-sided
noncompliance: the arm in which the stratum variable is constant is not
fitted and its empty stratum has share zero.
What de Chaisemartin and D'Haultfoeuille's DiD textbook found
The four applications of the authors' difference-in-differences textbook
(SSC package `cc_xd_didtextbook`) were run in Stata 18 and again in
StatsPAI. Six results were wrong (⚠️ below), the authors' own diagnostic
was missing, and their main estimator refused the book's central example.
Details, one documented departure from the reference command and what is
still open are in `docs/dev/2026-10-05-dcdh-did-textbook-review.md`.
- ⚠️ **`sp.did_multiplegt` with a non-binary treatment.** Every switcher
from a baseline other than 0 was signed as a switch off, whichever way
it moved, and nothing was divided by the size of the change. On the
newspaper panel of Gentzkow, Shapiro and Sinkinson (2011) the estimate
was -0.00082. It is now the effect per unit of treatment, 0.0057791,
which is what Stata `did_multiplegt_old` returns (1e-8), as are the two
placebos. Binary treatments give the same numbers as before.
- ⚠️ **`sp.sun_abraham` on an unbalanced panel.** The weight of a cohort
at a relative time was its share of units. It is now its share of the
observations at that relative time (of their weight under `weights=`),
as in Sun and Abraham (2021) and Stata `eventstudyinteract`. On the
Wolfers (2006) divorce panel, where 52 state-years have no outcome, the
unweighted effect at relative time 0 was -0.1399 and is -0.1054, Stata's
number to nine digits. Weights that vary over time now give Stata's
estimates and standard errors as well. Balanced panels are unchanged.
- ⚠️ **`sp.sun_abraham(event_window=)`.** Relative times outside the
window were left out of the regression, which puts their treated
observations in the reference category. On the Wolfers panel a window of
(-14, 12) moved the effect at relative time 0 from 0.246 to 0.502. The
window now selects what is reported and the regression stays saturated
(`window_rule='report'`, the new default). `window_rule='bin'` pools the
relative times beyond each end into that end, the specification usually
written for `eventstudyinteract`; `window_rule='reference'` is the old
behaviour.
- ⚠️ **`sp.did_multiplegt_dyn(same_switchers=True)`** kept every switcher
observed at all the horizons, including those with no control left at
the long ones, so the switchers were not the same across horizons. It
now keeps the switchers whose effect is estimable at every horizon, as
Stata does.
- ⚠️ **`sp.did_multiplegt_dyn(aggregation='switchers')`** divides the
summed effects by the treatment changes in place at each horizon, which
is the definition of `Av_tot_eff`. Nothing changes for a binary
treatment that stays switched; a treatment that comes back within the
horizons gave a different number before.
- ⚠️ **`sp.did_multiplegt_dyn(controls=)`.** The first-difference
regression behind the option was fitted without the groups that never
switch, whose outcomes were then left unadjusted, and on a panel with
holes a change between two rows several periods apart was used as a
one-period change. The regression also ignored `weights=` and
`trends_nonparam=`. And the analytic variance treated the fitted slopes
as known; it now has the term for their estimation (it was 0.1 to 0.6%
off). Estimates and standard errors agree with Stata to 5e-7 in six
configurations: plain, weighted and clustered, within
`trends_nonparam` cells, normalized, one switch direction, and on an
unbalanced panel.
- ⚠️ **`regress y x, vce(hc2 clustvar, dfadjust)` through `sp.stata` /
`sp.from_stata`** came back as `sp.regress(robust='hc2')`: the cluster
variable and the degrees-of-freedom adjustment were dropped without a
note. It is now `vce='cr2', cluster=, dfadjust=True`.
- **`sp.twowayfeweights`** (new). The weights a two-way fixed effects
regression puts on the treatment effects of the treated cells (de
Chaisemartin and D'Haultfoeuille 2020), for the fixed effects regression
(`type='feTR'`) and the first-difference regression (`'fdTR'`), with
controls, with other treatments in the regression (their contamination
weights are reported too), with observation weights, and with the
regression of chosen variables on the weights. Any treatment, binary or
not, staggered or not. Every count, sum, coefficient and test of the
nine calls in the book agrees with Stata `twowayfeweights`.
- **`sp.did_multiplegt_dyn` takes a non-binary treatment.** Periods are
ranked, so a four-yearly panel works; cells where a group has been on
both sides of its starting treatment are dropped; switchers are matched
on their period-one level. Effects, placebos, analytic standard errors,
normalized effects and `Av_tot_eff` agree with Stata `did_multiplegt_dyn`
on the synthetic test panel and, once one county the command discards by
accident is set aside, on the newspaper panel.
- **Joint tests without the bootstrap.** With `se_method='analytic'`,
`joint_placebo_test`, the new `joint_effects_test`, `joint_overall_test`
and `effects_equal_test` are Wald tests on the analytic joint covariance
and reproduce Stata's p-values. No replicate is drawn any more, so an
analytic fit no longer spends its time on 500 bootstrap draws it did not
use for the standard errors.
- **`design=`, `by_path=` and `normalized_weights=`** in
`sp.did_multiplegt_dyn`: the treatment paths behind an effect, the
effects path by path, and the weights of a normalized effect on the
current and lagged treatments.
- **`sp.regress(dfadjust=True)`**: Bell-McCaffrey degrees of freedom for
`robust='hc2'` and for `vce='cr2'` with `cluster=`, as Stata 18
`vce(hc2 [clustvar], dfadjust)`. `sp.test` now works after
`vce='cr2'` / `'cr3'`, whose full covariance is stored.
- **Translations**: `twowayfeweights`, `did_multiplegt_dyn`, `did_had`
and `did_multiplegt_old` run through `sp.stata`.
- **A sup-t test in `sp.uniform_bands`**, and `terms=`. The result's
`attrs` now carry `max_t` and `supt_pvalue`, the test that every covered
coefficient is zero. It is the test the textbook recommends when many
correlated pre-trend estimates make the Wald test's covariance nearly
singular. `terms=[...]` covers named coefficients of any regression, for
an event study written by hand with lead and lag dummies. On the book's
two examples the p-values are 0.068 and 0.608, against 0.068 and 0.607
from Stata `sotable`.
- **`sp.fect` on large panels.** The initial two-way fit built one dummy
column per unit, so a panel of 7,248 units and 40 periods needed a 17 GB
matrix and did not finish. Above 50 million design cells the same
least-squares fit is solved by alternating projections (equal to the
dense fit to 1e-11 where both run); that panel now takes 0.4 seconds
for `method='fe'`. Smaller panels take the same path as before.
- **`sp.fect` warns when the iterations stop at `max_iter`**
(`ConvergenceWarning`). `model_info['converged']` already said so, and
nothing else did. With weak factors the ATT can keep moving for
thousands of iterations: on the Moser and Voena (2012) panel the
two-factor estimate is 0.277 at `tol=1e-3`, 0.131 at `1e-4` and 0.051 at
`1e-5`.
A causal-ML textbook's notebooks, run against their references
The Python notebooks of Chernozhukov, Hansen, Kallus, Spindler and
Syrgkanis, *Applied Causal Inference Powered by ML and AI*
(arXiv:2403.02467), were rerun in StatsPAI next to R `hdm`, `sensemakr`,
`dagitty`, `rdrobust` and Python `DoubleML`. Double lasso, the DML models,
IRM, the ATT score for difference-in-differences, PLIV, proxy controls and
`rdrobust` with covariates reproduced their references. What did not is
below. Every fix is pinned on simulated data; the book's data are not
redistributed. Reader guide: `docs/guides/applied_causal_ml.md`.
⚠️ Correctness
- **`sp.dml(model='plr')` and `model='pliv'` with a classifier nuisance
used the predicted label, not the predicted probability.** Passing
`ml_m=RandomForestClassifier()` for a 0/1 treatment residualised it as
`D - 1{p > 0.5}`. On the book's 401(k) example the estimate was 6,044
where `DoubleML` and the book give 8,754. A classifier now contributes
`predict_proba` for a 0/1 outcome, treatment or instrument, and is
refused for any other target. Regressors, `model='irm'` and
`model='iivm'` are unchanged. `sp.dml_model_averaging` had the same
defect and the same fix. See MIGRATION `oct2026-causalml-textbook-fixes`.
- **`sp.dml_sensitivity` on an IRM fit used the PLR scaling.** The bound
for the average effect needs `sigma^2 = E[(Y - g(D, X))^2]` and the
Riesz representer `D/m - (1-D)/(1-m)`. The old bound on the 401(k)
example was [-1,276, 16,870]; it is now [2,940, 12,654], equal to
`DoubleML`'s to 1e-10, for the ATE and the ATTE scores.
- **`sp.dml_sensitivity`'s `rv_qa` now accounts for the sampling error of
the bias bound.** It is the confounding strength at which the interval
for the bound reaches zero. It used to subtract `1.96 * se` of the
unadjusted estimate, which ignores that `S` is estimated. On the 401(k)
PLR fit `rv_qa` moves from 0.0486 to 0.0511.
Added
- **`sp.lm_lin`**: the average effect of a randomized experiment by
regression on treatment, centred covariates and their interactions (Lin
2013). Estimate, standard error, degrees of freedom and interval equal R
`estimatr::lm_lin` 2.0.0 to 1e-9 for HC0 to HC3, classical, CR2 (with
Bell-McCaffrey degrees of freedom) and Stata clustering.
`superpopulation=True` adds the variance from estimating the covariate
means, which the book's experiments chapter adds by hand: with random
covariates and heterogeneous effects the regression interval covered
the population effect 83% of the time in simulation and the corrected
one 94%.
- **Standard errors and a uniform band for the TOC curve of
`sp.cate_eval`.** `toc_curve` gains `se`, `ci_lower` / `ci_upper` and
`band_lower` / `band_upper`; the band covers the whole curve, so a
stretch of it above zero is evidence that the score found heterogeneity.
The influence function includes the term for ranking within the
evaluation sample; without it the standard error is more than 10% too
large at the top of the curve. Checked against a known curve
(`tests/reference_parity/test_cate_eval_toc_bands.py`). `m_hat` is no
longer required when `mu1_hat` and `mu0_hat` are given. AUTOC and Qini
are unchanged (equal to grf's to 1e-10).
- **Standard errors and an interval for the sensitivity bounds.**
`sp.dml_sensitivity(...)` returns `se_low`, `se_high`, `ci_low` and
`ci_high` for PLR and IRM fits, with clusters and with repeated
cross-fitting. `DoubleML` 0.11.3 reports the same two standard errors
with the bounds exchanged; a finite-difference check of the influence
functions (`tests/reference_parity/test_dml_sensitivity_bound_scores.py`)
and the book's own code agree with the assignment used here.
- **A weak-instrument-robust confidence set for PLIV.**
`sp.dml(model='pliv')` stores `model_info['anderson_rubin']`: the exact
solution of the Anderson-Rubin inequality on the cross-fitted score. It
is an interval when the first stage is significant and unbounded
otherwise. The book scans a grid, which truncates the set (its AJR
example prints an upper end of 1.99; the set extends to 3.67).
- **`sp.best_linear_projection` accepts `sp.dml(model='irm')` results.** It
projects the cross-fitted doubly-robust scores on covariates or group
indicators, which is `DoubleML`'s `cate()` / `gate()`. Coefficients match
to 1e-10; the joint covariance is in `.attrs['vcov']`, for causal
forests as well, so the projected effect at any covariate value has a
standard error.
- **Joint inference for `sp.rlasso_effects`.** The return value is still a
`dict`, now with `.vcov()` (equal to hdm's to 1e-14) and
`.conf_int(joint=True)`, a sup-t band as in
`confint(<rlassoEffects>, joint = TRUE)`.
- **`sp.sensemakr`** takes string and categorical controls (they enter as
indicators and are benchmarked as a group), takes `kd=` / `ky=`
multiples, and reports the adjusted estimate, standard error, t and
interval for each benchmark, equal to R `sensemakr` 0.1.6 to 1e-9. It
used to stop with a NumPy casting error on the package's own Darfur
example. The treatment standard error is now computed from a QR factor
(1e-14 from R on Track A module 22, was 5e-8).
- **`sp.dag`** accepts named unobserved nodes (`latent=[...]` or
`"F [latent]"`): they are never offered as adjustment variables and
`sp.identify` projects them out (`DAG.latent_projection()`).
`DAG.implied_independencies()` lists the testable implications of a
graph and `DAG.test_implications(data)` tests them; both equal
`dagitty`'s output.
Fixed
- `sp.proximal_regression` refuses a treatment that is not 0/1. It used to
truncate a continuous treatment to integers and return a number.
- `sp.dml_sensitivity` refuses PLIV and IIVM fits with a reason instead of
asking for residuals that those models never store.
What Clarke's *Applied Microeconometrics* found
The Stata chapters of the book's companion site (randomisation inference
and the bootstrap, matching and weighting, clustered inference and the
two-way fixed effects decompositions, synthetic control and synthetic
difference in differences) were run in Stata 18 and replayed through
`sp.stata`. Method and ledger in
`docs/dev/2026-10-04-clarke-applied-microeconometrics-review.md`.
⚠️ Correctness
- **`sp.twfe_decomposition` now decomposes the TWFE coefficient.** Its
2×2 rows were weighted by unit counts and its timing comparisons were
not restricted to their windows, so the rows did not sum to the
coefficient, and its "dCDH" weights were not de Chaisemartin and
D'Haultfoeuille's. On the book's three-unit panel, where every piece is
known by hand, the headline was 1.839 for a coefficient of 27/11. The
rows are now those of `sp.bacon_decomposition` and sum to the
coefficient exactly, the weights are the dCDH (2020) weights, the
estimate is the TWFE coefficient and the standard error is its
unit-clustered one (equal to `xtreg, fe vce(cluster)` to 1e-10). The
comparison with R `bacondecomp` and `twowayfeweights` on `mpdta`, a
strict `xfail` until now, passes. See MIGRATION.
- **Propensity-score matching: units with the same covariates now tie.**
The fitted score of two units with identical covariates could differ in
the last place, because a matrix product rounds each row on its own.
With every tied control kept (`ties='all'`, what `teffects psmatch`
does) that rounding decided which of several identical controls was
matched, and the estimate changed when the rows were shuffled. The
index is now evaluated once per distinct covariate row. On the Lalonde
data with discrete covariates `teffects psmatch, atet` gives 1209.718
(1261.376); StatsPAI gave 1199.666 (1266.607) and now gives 1209.718
(1261.376). Samples with continuous covariates are unaffected. See
MIGRATION.
- **`sp.stata` / `sp.from_stata`: three translations ran something other
than the Stata command.**
- `teffects ipw` was translated to `sp.ipw` with its default bootstrap
standard error. It now asks for the stacked M-estimation standard
error `teffects ipw` reports (`se_method='sandwich'`).
- `teffects aipw` was translated to `sp.aipw` with its default
five-fold cross-fitting, a different (and random) estimator. It now
runs `cross_fit=False, se_method='sandwich'`, and `atet`, which
`teffects aipw` does not have, is refused.
- `boottest` was translated to a call of `sp.wild_cluster_bootstrap`
with arguments that function does not take, so it raised a
`TypeError`. It now runs `sp.wild_cluster_boot` on the regression
before it, with `weight()`, `reps()`, `seed()`, `level()` and a null
value (`boottest x = c`). With few clusters the Rademacher draws are
enumerated and the p-value equals Stata's (0.09179688 on the book's
regression). A `bootcluster()` other than the error cluster, a joint
hypothesis and an unknown weight are refused.
Added
- **A probit propensity score.** `sp.ipw`, `sp.match` and `sp.psmatch2`
take `ps_model='probit'` (default `'logit'`, as before). The sandwich
standard error of `sp.ipw` and the Abadie-Imbens (2016) standard error
of `sp.match` use the probit's score, density and observed Hessian.
Against Stata 18 on the Lalonde data, to the seven digits `teffects`
prints: `teffects ipw (..., probit)` ATET and ATE with their standard
errors, `teffects psmatch (..., probit), atet` with its standard error,
and `psmatch2` (whose default is a probit) to 1e-7.
`tests/reference_parity/test_teffects_probit_stata.py` holds the
numbers. `teffects ... , probit` and `psmatch2` without `logit` are
translated accordingly; they were refused or run as a logit.
- `sp.wild_cluster_boot(h0=)`: the value of the coefficient under the
null. `confidence_set=True` adds `ci_inverted`, the set of null values
the test does not reject, which is what `boottest` prints; the
translated `boottest` asks for it.
- **The Abadie-Imbens (2016) standard error for the ATE.**
`sp.match(estimand='ATE', se_method='abadie_imbens_2016')` reports what
`teffects psmatch, ate` reports, to the seven digits it prints on five
reference fits. The Stata manual prints the score adjustment with a
plus sign; `e(V)` and the paper have a minus (estimating the score can
only lower the variance of the ATE), and that is what is implemented.
`teffects psmatch, ate` is translated with it; it used to be flagged as
not carrying Stata's standard error.
- **`egen` in `sp.stata`**: `count`, `mean`, `median`, `sd`, `min`, `max`,
`total`, `pctile`, `iqr`, `std`, `group`, `tag` and the `row*`
functions, with `if`, `in`, `by()` and behind `by g:`. Each follows the
missing-value rule of `[D] egen`. Run beside Stata 18 on 30 commands:
27 agree to 1e-15 on every row, the other three depend on a row order
Stata's sort does not fix.
- **`sp.stata` runs more of an ordinary do-file.** `quietly { ... }`,
`capture { ... }` and `noisily { ... }` blocks; `capture cmd`, which
swallows what Stata would raise (a variable that is not there,
`restore` without `preserve`) and nothing else; `by g:` / `bysort g
(t):` in front of `generate` and `replace`, with `_n`, `_N` and
subscripts counted within the group; `count [if] [in]`; `_pctile` and
the percentiles, skewness and kurtosis `summarize, detail` leaves in
`r()`; `scalar(name)`; `_b["x"]`; `duplicates drop [varlist, force]`;
`bsample [, cluster()]` (the data are then marked as random);
`predict` after a regression with `i.` variables; the variables
`psmatch2` leaves behind (`_pscore`, `_weight`, `_support` ...); a
`scalar` defined with no data in memory. Loops, frames and matrices
are still refused.
- `scripts/stata_log_replay.py` reads `import delimited` (Stata's
delimiter detection and lower-cased names) and `input` blocks, and
compares `sdid`, `boottest` and `psmatch2` output. It reads a block as
one command (the body from the log's numbered echo, or from the do-file
beside the log when the block ran quietly) and keeps macros, scalars,
matrices and saved datasets across `use`.
- **`sp.stata` runs loops.** `forvalues`, `foreach`, `while`, `if { } else
{ }` and `continue`, with the macros they use (`local x = exp`, `local
++i`, `` `=exp' ``, `` `r(N)' ``, `tempvar`, `tempfile`) and programs
called with arguments. A block's body is run line by line, so each
command in it is translated and refused as it would be outside. Extended
macro functions, `syntax` and `mata` are still refused.
- **`sp.stata` holds more than one dataset.** `save` keeps a copy in the
session (no file is written); `use`, `append using`, `merge 1:1 | m:1 |
1:m ... using` work on those copies and on the
frames passed in the new `files=` argument. Frames (`frame create / copy
/ change / put / post`, `frlink`, `frget`) are run. `merge`, `append`,
and `frlink` / `frget` were run beside Stata 18 and give its
rows in its order.
- **Matrices in `sp.stata`**: `matrix A = J(r, c, .)`, `e(b)`, `e(V)`, a
cell at a time, `A[i, j]` in expressions, `svmat`, `mkmat`, and matrix
expressions with sums, products, a transpose and `inv()`.
- More of `egen`: `skew`, `kurt`, `mad`, `mdev`, `mode`, `rank`, `seq`,
`anycount`, `anymatch`, `cut(, at())`, `rowfirst`, `rowlast`. Nineteen
commands beside Stata 18, all equal on every row.
- `sp.match(estimand='ATE', se_method='abadie_imbens_2016', caliper=)`:
as in Stata the caliper is a condition every unit has to meet, and the
call stops when one does not.
- `sp.causal_forest` accepts `treat=` and `covariates=` for `d=` and `x=`.
- `display "text" exp` evaluates the expression.
With these the three chapter logs of the book run with one command
declined (`regress, vce(bootstrap)`), down from 107.
⚠️ Correctness: local randomization and multi-cutoff RD
Found by running the replication files of Cattaneo, Idrobo and Titiunik
(2024), *A Practical Introduction to Regression Discontinuity Designs:
Extensions*, through StatsPAI and comparing every number with R
`rdlocrand` 2.0, `rdmulti` 2.0 and `rddensity` 2.6. `sp.rdrobust` (20
calls, including clustered, fuzzy and mass-point cases) and the
`sp.rddensity` test agreed to 1e-11 and are unchanged. See MIGRATION.md
for each item below.
- **`sp.rdrandinf(p=1)` estimated the wrong quantity.** It residualized
the outcome on one polynomial pooled across the cutoff. The score is
collinear with treatment inside the window, so the trend absorbed the
effect: 2.90 (p = 0.107) on the Senate data in a ±2.5 window, where the
difference in intercepts is 13.26. It now fits each side separately and
matches `rdlocrand` to 1e-13 in the statistic and the large-sample
p-value, for every kernel, order and evaluation point tried.
- **`sp.rdrandinf(fuzzy=)` reported a p-value that meant nothing.** It
permuted the instrument and recomputed the Wald ratio, so most draws
divided by a first stage near zero. On the book's fuzzy example it
returned p = 0.863 for an effect whose 2SLS p-value is 0.038. The
default is now the randomization test on the reduced form
(`fuzzy_stat='itt'`); `fuzzy_stat='tsls'` gives the large-sample test.
- **`sp.rdrandinf` / `sp.rdrbounds`: `wl` and `wr` are the window's
endpoints**, as in `rdlocrand`, not offsets from the cutoff. The two
agree only at `c = 0`; the book's placebo-cutoff example (`c=1,
wl=0.2348, wr=1.7652`) raised an error. A window that does not contain
the cutoff is now refused with a message that says why.
- **`sp.rdwinselect` did not run the window-selection procedure.** Its
default sequence started at a fraction of the score's range (5, 15.6,
..., 100 points on the Senate data) instead of at the smallest window
with ten observations a side, had no binomial test, did not say which
covariate was least balanced, and without covariates tested quantile
dummies of the score and returned p = 1. It now reproduces Snippet 2.5
of the book: the ten windows, their counts, the binomial p-values and
the recommended window [-0.7652, 0.7652].
- **`sp.rdmc(cutoff_var=)` reported conventional intervals.** The point
estimates matched `rdmulti::rdmc`; the interval and p-value were the
conventional ones, which ignore smoothing bias. On the book's example
the third cutoff went from [0.017, 0.401], p = 0.033 to the robust
[-0.042, 0.408], p = 0.112. The estimate on the normalized score
(`rdmulti`'s "Pooled" row) is now reported as `result.normalized`.
- **`sp.rdrobust`'s automatic manipulation check is now `sp.rddensity`.**
It was McCrary's binned test, which is not built for a score with mass
points. On the book's academic-probation data (429 GPA values) it warned
of sorting with p = 6e-11 where `sp.rddensity` gives 0.082, the number
the book reports; over 60 placebo cutoffs on that data, where nothing
is manipulated, it rejected 43% of the time against 5%. Under simulated
sorting on a continuous score both tests reject every time. Estimates,
standard errors and intervals are unchanged; what changes is when the
warning fires and the value in `model_info['mccrary']['pvalue']` (the
key keeps its name; `'test'` beside it says `'rddensity'`).
`sp.mccrary_test` itself is unchanged.
Added: local randomization options
- `sp.rdrandinf`: `kernel=` (it was accepted and ignored), `nulltau=`,
`bernoulli=`, `ci=` (a grid of effects for the test-inversion
interval), `d=` / `dscale=` (large-sample power, in
`model_info['power']`), `evall=` / `evalr=`, `fuzzy_stat=`.
- `sp.rdwinselect`: `obsmin=`, `wobs=`, `wasymmetric=`, `approx=`,
`n_perms=`, `kernel=`, `dropmissing=`; columns `variable` and
`binom_pvalue`; `attrs['recommended_window']`.
- `sp.rddensity`: `bino_w=`, `bino_wstep=`, `bino_n=`, `bino_nw=`,
`bino_p=` for the binomial tests.
- `sp.rdmcplot`: the RD plot for a design with several cutoffs, one
binned scatter and polynomial fit per cutoff on a shared axis
(`rdmulti::rdmcplot`). The numbers are `sp.rdplot`'s on each cutoff's
units and are returned in `fig.rdmcplot_data`.
- `sp.bitest`: the exact binomial test of Stata's `bitest` / `bitesti`
and R's `binom.test`, from a 0/1 column or from counts. Agrees with
Stata 18 to 1e-12, including `r(k_opp)`. `sp.stata` runs both commands
and stores their `r()` results.
- `sp.rdrandinf(interfci=)`: Rosenbaum's (2007) confidence interval under
interference between units, in `model_info['interf_ci']`.
- `sp.rdwinselect(statistic='hotelling')`: one joint balance test of all
covariates per window (Hotelling's T-squared), with the F p-value under
`approx=True`; agrees with `rdlocrand` to 1e-9.
- `sp.rdwinselect(wmasspoints=True)`: for a discrete score, window `k`
runs from the `k`-th support point below the cutoff to the `k`-th at or
above it, as in `rdlocrand` 1.0 (same windows, counts and p-values).
Releases 1.1 and 2.0 pair the `k`-th on the right with the `(k-1)`-th
on the left, so their first window is empty below the cutoff; on the
book's academic-probation data the first window here is the one the
book itself analyses (208 and 67 observations).
- `sp.from_stata` / `sp.stata`: `rdms` (the boundary points are read from
the `cvar()` variables by `sp.stata`), `bitest`, `bitesti`, and the
`interfci()` and `wmasspoints` options.
- `sp.rdms` without `x2`: one score with cumulative cutoffs
(`rdmulti::rdms(Y, X, C)`), a sharp RD at each cutoff on the whole
sample or, with `ranges=`, on the units in each cutoff's interval.
Agrees with `rdmulti` to 1e-9. `sp.stata` runs `rdms y x, cvar(c)
range(lo hi)` and `rdmcplot y x, cvar(c) pvar() nbinsvar() ...`,
reading the per-cutoff values from the named variables.
- `sp.rdms`: lists for `cutoff1=` / `cutoff2=` estimate several boundary
points in one call and return one row per point; `xnorm=` adds the
pooled estimate on the distance to the boundary. Both agree with
`rdmulti::rdms` to 1e-9 (`test_rdms_points_parity.py`). A call with
scalar cutoffs returns what it did before.
Where StatsPAI departs from `rdlocrand`, and why
- **Randomization p-values with `p > 0`.** `rdlocrand` re-randomizes
treatment labels with the scores held fixed. The observed statistic
extrapolates each side's fit to the cutoff; a relabelled sample fits
through the interior, with about a quarter of the variance. On data
with no effect, 40 observations and `p = 1`, that test rejects a true
null 37% of the time at the 5% level. `sp.rdrandinf` permutes outcomes
against (score, assignment) pairs and rejects 4.7% (3,000 replications)
(`tests/reference_parity/test_rdlocrand_extensions_parity.py`). For
`p = 0`, with or without a kernel, the two schemes coincide and the
60-seed mean p-values agree with R's.
- **The default windows of `sp.rdwinselect`** are those of `rdlocrand`